新增: 父子任务支持(数据→后端→前端全链路,会话前基线收尾)
- df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具 - 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板 - 设计文档: 父子任务支持设计-2026-08-04
This commit is contained in:
+323
-2
@@ -71,6 +71,13 @@
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
|
||||
<div class="task-info">
|
||||
<!-- F-260805 父子任务:父面包屑,仅子任务(parent_id 有值)显示,跳 /tasks/{parent_id} -->
|
||||
<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">{{ parentTaskTitle || task.parent_id }}</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.project') }}</span>
|
||||
<span class="value">
|
||||
@@ -116,6 +123,54 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- F-260805 父子任务:子任务面板。仅顶层任务(无 parent_id)可作为父任务(1 级嵌套约束),
|
||||
顶层无子时显示空态 + 添加入口(空树创建)。 -->
|
||||
<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>
|
||||
<!-- 顶部进度条 + 计数(done+cancelled / total) -->
|
||||
<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}`)"
|
||||
>
|
||||
<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>
|
||||
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleChildMenu(child.id)">⚙️</button>
|
||||
<!-- 子任务行快捷菜单(复用列表页 quickStatuses/quickPriorities 模式,advance 后刷新子列表) -->
|
||||
<div v-if="childMenuId === child.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="advanceChild(child, s.key)">
|
||||
<span>{{ s.icon }}</span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-menu-divider"></div>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="setChildPriority(child, p.value)">
|
||||
<span :class="p.cls">●</span>{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 空态:父任务无子任务 -->
|
||||
<div v-else class="empty-hint subtask-empty">{{ $t('taskDetail.childEmpty') }}</div>
|
||||
<button class="btn btn-ghost btn-sm subtask-add" type="button" @click="openSubtaskModal">{{ $t('taskDetail.addSubtask') }}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
|
||||
@@ -144,13 +199,41 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- F-260805:子任务新建小弹窗(标题 + 优先级 + 可选描述),project_id/parent_id 继承当前任务 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { taskApi, projectApi, ideaApi } from '@/api'
|
||||
import { workflowApi } from '@/api'
|
||||
@@ -169,6 +252,7 @@ import type { NodeStatus } from '@/components/workflow/WorkflowDagDisplay.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMsg = ref('')
|
||||
@@ -178,6 +262,126 @@ const projects = ref<ProjectRecord[]>([])
|
||||
const ideas = ref<IdeaRecord[]>([])
|
||||
const advancing = ref(false)
|
||||
|
||||
// ============================================================
|
||||
// F-260805 父子任务:父面包屑 + 子任务面板
|
||||
// ------------------------------------------------------------
|
||||
// 本视图绕 store 直调 taskApi,父子数据本地维护:
|
||||
// - 子任务(parent_id 有值):额外取父任务标题供面包屑
|
||||
// - 顶层任务(无 parent_id):拉子任务列表 + 父进度(前端计算),advance/改优先级后手动刷新
|
||||
const children = ref<TaskRecord[]>([])
|
||||
const parentTaskTitle = ref<string | null>(null)
|
||||
// 子任务行快捷菜单(当前打开的子任务 id)
|
||||
const childMenuId = ref<string | null>(null)
|
||||
function toggleChildMenu(id: string) {
|
||||
childMenuId.value = childMenuId.value === id ? null : id
|
||||
}
|
||||
function closeChildMenu() { childMenuId.value = null }
|
||||
|
||||
// 复用列表页 quickStatuses/quickPriorities 模式(子任务行快捷推进)
|
||||
const quickStatuses = computed(() => [
|
||||
{ key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') },
|
||||
{ key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') },
|
||||
{ key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') },
|
||||
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
|
||||
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
|
||||
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
|
||||
])
|
||||
const quickPriorities = computed(() => [
|
||||
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
|
||||
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
|
||||
{ value: 2, label: t('tasks.modal.priorityMedium'), cls: 'priority-medium' },
|
||||
{ value: 3, label: t('tasks.modal.priorityLow'), cls: 'priority-low' },
|
||||
])
|
||||
|
||||
/** 子任务父进度 = children 中 done+cancelled / total(仅顶层任务有 children) */
|
||||
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 }
|
||||
})
|
||||
|
||||
/** 拉取当前任务的直接子任务(仅顶层任务可作父,1 级嵌套) */
|
||||
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 (e) {
|
||||
console.error('[TaskDetail] 拉取子任务失败:', e)
|
||||
children.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 取父任务标题(父面包屑用;取不到时回退显示父 id) */
|
||||
async function loadParentTitle() {
|
||||
const cur = task.value
|
||||
if (!cur || !cur.parent_id) { parentTaskTitle.value = null; return }
|
||||
try {
|
||||
const parent = await taskApi.get(cur.parent_id)
|
||||
parentTaskTitle.value = parent.title
|
||||
} catch (e) {
|
||||
console.error('[TaskDetail] 拉取父任务标题失败:', e)
|
||||
parentTaskTitle.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 子任务快捷推进(advance 后刷新子列表,父进度随之更新) */
|
||||
async function advanceChild(child: TaskRecord, target: string) {
|
||||
childMenuId.value = null
|
||||
try {
|
||||
await taskApi.advance(child.id, target)
|
||||
await loadChildren()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||
}
|
||||
}
|
||||
|
||||
/** 子任务快捷改优先级(update 后刷新子列表) */
|
||||
async function setChildPriority(child: TaskRecord, priority: number) {
|
||||
childMenuId.value = null
|
||||
try {
|
||||
await taskApi.update(child.id, 'priority', String(priority))
|
||||
await loadChildren()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||
}
|
||||
}
|
||||
|
||||
// F-260805:子任务新建小弹窗
|
||||
const showSubtaskModal = ref(false)
|
||||
const subtaskTitle = ref('')
|
||||
const subtaskDesc = ref('')
|
||||
const subtaskPriority = ref(2)
|
||||
const subtaskSubmitting = ref(false)
|
||||
function openSubtaskModal() {
|
||||
subtaskTitle.value = ''
|
||||
subtaskDesc.value = ''
|
||||
subtaskPriority.value = 2
|
||||
showSubtaskModal.value = true
|
||||
}
|
||||
async function confirmSubtask() {
|
||||
const cur = task.value
|
||||
if (!cur || !subtaskTitle.value.trim() || subtaskSubmitting.value) return
|
||||
subtaskSubmitting.value = true
|
||||
try {
|
||||
const r = await taskApi.create({
|
||||
project_id: cur.project_id,
|
||||
title: subtaskTitle.value.trim(),
|
||||
description: subtaskDesc.value.trim() || undefined,
|
||||
priority: subtaskPriority.value,
|
||||
// project_id 继承当前任务、parent_id = 当前任务 id
|
||||
parent_id: cur.id,
|
||||
})
|
||||
if (!r) return
|
||||
showSubtaskModal.value = false
|
||||
await loadChildren()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = t('taskDetail.addSubtaskFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||
} finally {
|
||||
subtaskSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
|
||||
// ------------------------------------------------------------
|
||||
@@ -458,6 +662,8 @@ async function load() {
|
||||
task.value = t
|
||||
projects.value = ps
|
||||
ideas.value = ideasList
|
||||
// F-260805:父子数据(子任务取父标题 / 顶层任务取子任务列表),与主数据并行
|
||||
await Promise.all([loadParentTitle(), loadChildren()])
|
||||
} catch (e: any) {
|
||||
task.value = null
|
||||
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||
@@ -489,6 +695,8 @@ let _unlistenWorkflowEvent: (() => void) | null = null
|
||||
onMounted(async () => {
|
||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||||
load()
|
||||
// F-260805:点击子任务快捷菜单外关闭菜单
|
||||
document.addEventListener('click', closeChildMenu)
|
||||
// B-260616-18: 后端 AI 工具(create/update/delete 等)emit df-data-changed → 本视图重载当前 task
|
||||
// entity=task:任务本体字段(标题/状态/描述/分支…)被改时刷新;entity=project:项目名变更影响 projectName 解析时刷新
|
||||
try {
|
||||
@@ -513,6 +721,8 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||||
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
|
||||
// F-260805:移除子任务快捷菜单关闭监听
|
||||
document.removeEventListener('click', closeChildMenu)
|
||||
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
|
||||
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
||||
})
|
||||
@@ -683,7 +893,7 @@ onBeforeUnmount(() => {
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
background: var(--df-accent-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-family: monospace;
|
||||
@@ -698,6 +908,117 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.error-hint { color: var(--df-danger); }
|
||||
|
||||
/* ===== F-260805 父子任务:子任务面板 ===== */
|
||||
.subtask-count {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subtask-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.subtask-progress-text {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 迷你进度条(与 Tasks.vue 树形列表同款,渐变填充) */
|
||||
.mini-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: linear-gradient(90deg, var(--df-accent), var(--df-success));
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.subtask-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
cursor: pointer;
|
||||
position: relative; /* 快捷菜单绝对定位锚点 */
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.subtask-item:hover { background: var(--df-accent-bg); }
|
||||
.subtask-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--df-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subtask-quick-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.subtask-item:hover .subtask-quick-btn { opacity: 0.6; }
|
||||
.subtask-quick-btn:hover { opacity: 1; }
|
||||
/* 子任务快捷菜单(复用列表页 quick-menu 同款视觉) */
|
||||
.quick-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||
z-index: 20;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.quick-menu-section { padding: 4px 0; }
|
||||
.quick-menu-label {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
padding: 2px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.quick-menu-divider {
|
||||
height: 0.5px;
|
||||
background: var(--df-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.subtask-empty { padding: 16px 8px; }
|
||||
.subtask-add { margin-top: 8px; }
|
||||
|
||||
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
|
||||
@media (max-width: 960px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user