新增: 父子任务支持(数据→后端→前端全链路,会话前基线收尾)

- df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具

- 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板

- 设计文档: 父子任务支持设计-2026-08-04
This commit is contained in:
lxy
2026-08-05 22:15:01 +08:00
parent 71fdaac1b4
commit 28de5d6143
13 changed files with 1097 additions and 307 deletions
+14 -2
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
import type { TaskRecord, CreateTaskInput, TaskQuery } from './types'
import type { TaskRecord, CreateTaskInput, TaskQuery, TaskTreeNode, TaskDeleteResult } from './types'
export const taskApi = {
/**
@@ -35,10 +35,22 @@ export const taskApi = {
return invoke('update_task', { id, field, value })
},
delete(id: string): Promise<boolean> {
/**
* 删除任务。后端 delete_task 现为级联软删:删除父任务时一并软删其子任务
* (返回 TaskDeleteResult.ok + cascaded 子任务数);删除子任务仅删自身。
*/
delete(id: string): Promise<TaskDeleteResult> {
return invoke('delete_task', { id })
},
/**
* 获取任务树(父子任务,1 级嵌套,无孙任务)。
* 后端 get_task_tree 按 parentId 返回 TaskTreeNode(parent + 直接 children)。
*/
getTree(id: string): Promise<TaskTreeNode> {
return invoke('get_task_tree', { parentId: id })
},
/**
* F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。
* 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时),
+8
View File
@@ -67,5 +67,13 @@ export default {
collapse: 'Collapse',
// Related info panel title (wide-screen right column / info grouping)
relatedTitle: 'Related',
// F-260805 parent/child tasks: parent breadcrumb + subtask panel
parentTask: 'Parent Task',
childrenTitle: 'Subtasks',
subtaskCount: '{n} subtasks',
childEmpty: 'No subtasks yet',
addSubtask: '+ Add Subtask',
progressTitle: 'Progress',
addSubtaskFailed: 'Failed to create subtask: {msg}',
},
}
+11
View File
@@ -39,6 +39,14 @@ export default {
empty: 'No tasks yet',
},
confirmDelete: 'Delete task "{title}"? This action cannot be undone.',
// F-260805 parent/child tasks: deleting a parent cascades soft-delete of children
confirmDeleteWithChildren: 'Delete "{title}"? {n} subtasks will also be deleted.',
// F-260805 parent/child tasks: tree child progress badge / mini progress bar tooltip
tree: {
progress: 'Progress',
},
// F-260805 parent/child tasks: add-subtask entry in parent quick menu
addSubtask: '+ Add Subtask',
// Quick action menu (task card quick status/priority/delete)
quickActions: 'Quick Actions',
quickStatus: 'Status',
@@ -64,6 +72,9 @@ export default {
priorityHigh: 'P1 High',
priorityMedium: 'P2 Medium',
priorityLow: 'P3 Low',
// F-260805 parent/child tasks: parent dropdown in create modal
parentTask: 'Parent Task',
parentPlaceholder: 'None (top-level task)',
},
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) — D-260616-01 aligned to backend 7 states
status: {
+8
View File
@@ -67,5 +67,13 @@ export default {
collapse: '收起',
// 关联信息面板标题(宽屏右栏 / 信息分组)
relatedTitle: '关联信息',
// F-260805 父子任务:父面包屑 + 子任务面板
parentTask: '父任务',
childrenTitle: '子任务',
subtaskCount: '{n} 个子任务',
childEmpty: '暂无子任务',
addSubtask: ' 添加子任务',
progressTitle: '完成进度',
addSubtaskFailed: '创建子任务失败: {msg}',
},
}
+11
View File
@@ -39,6 +39,14 @@ export default {
empty: '暂无任务',
},
confirmDelete: '确定删除任务「{title}」吗?此操作不可撤销。',
// F-260805 父子任务:删除父任务时级联软删子任务,确认文案含子任务数
confirmDeleteWithChildren: '确定删除「{title}」吗?将同时删除 {n} 个子任务。',
// F-260805 父子任务:树形列表子进度徽章/迷你进度条 tooltip
tree: {
progress: '进度',
},
// F-260805 父子任务:父任务快捷菜单「添加子任务」
addSubtask: ' 添加子任务',
// 快捷操作菜单(任务卡片快捷改状态/优先级/删除)
quickActions: '快捷操作',
quickStatus: '状态',
@@ -64,6 +72,9 @@ export default {
priorityHigh: 'P1 高',
priorityMedium: 'P2 中',
priorityLow: 'P3 低',
// F-260805 父子任务:新建弹窗「父任务」下拉
parentTask: '父任务',
parentPlaceholder: '无(顶层任务)',
},
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态
status: {
+14 -2
View File
@@ -22,7 +22,17 @@ export function createTasksStore() {
})
}
async function createTask(input: { project_id: ProjectId; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string; idea_id?: string }) {
async function createTask(input: {
project_id: ProjectId
title: string
description?: string
priority?: number
branch_name?: string
assignee?: string
idea_id?: string
/** 父任务 ID(1 级嵌套,可空);传空串后端视为 None */
parent_id?: string | null
}) {
const record = await runWithCatch(state, t('tasks.err.createFailed'), async () => {
const r = await taskApi.create(input)
state.tasks.push(r)
@@ -43,8 +53,10 @@ export function createTasksStore() {
async function deleteTask(id: string) {
await runWithCatch(state, t('tasks.err.deleteFailed'), async () => {
// 后端已级联软删子任务(delete_task 返回 { ok, cascaded });
// 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id)
await taskApi.delete(id)
state.tasks = state.tasks.filter(t => t.id !== id)
state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id)
})
}
+323 -2
View File
@@ -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; }
+330 -111
View File
@@ -10,7 +10,7 @@
:placeholder="$t('tasks.searchPlaceholder')"
@keyup.esc="searchKeyword = ''"
/>
<button class="btn btn-primary" @click="openCreateModal">{{ $t('tasks.create') }}</button>
<button class="btn btn-primary" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
</div>
</header>
@@ -50,7 +50,7 @@
<div v-else-if="filteredGroups.length === 0" class="empty-state">
<div class="empty-icon">📋</div>
<div>{{ $t('tasks.group.empty') }}</div>
<button class="btn btn-primary btn-sm" @click="openCreateModal">{{ $t('tasks.create') }}</button>
<button class="btn btn-primary btn-sm" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
</div>
<template v-else>
<section
@@ -63,71 +63,181 @@
<span class="group-chevron">{{ collapsedGroups.has(group.projectName) ? '▸' : '▾' }}</span>
<span class="group-icon">{{ group.icon }}</span>
<h2 class="group-name">{{ group.projectName }}</h2>
<span class="group-count">{{ group.tasks.length }}</span>
<span class="group-count">{{ group.rows.length }}</span>
</div>
<div v-show="!collapsedGroups.has(group.projectName)" class="task-list">
<div
class="task-item"
v-for="task in group.tasks"
:key="task.id"
@click="router.push(`/tasks/${task.id}`)"
>
<div class="task-main">
<div class="task-title-row">
<span class="task-title">{{ task.title }}</span>
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<template v-for="row in group.rows" :key="row.task.id">
<!-- 父任务行(有子任务):折叠箭头 + 📑 图标 + 子进度徽章 n/m + 迷你进度条 + 快捷菜单(含添加子任务)
点击行跳详情;折叠箭头 @click.stop 只切换展开不跳转 -->
<div
v-if="row.isParent"
class="task-item task-item-parent"
:class="{ 'is-expanded': expandedParents.has(row.task.id) }"
@click="router.push(`/tasks/${row.task.id}`)"
>
<div class="task-main">
<div class="task-title-row">
<button
class="fold-btn"
:class="{ 'is-open': expandedParents.has(row.task.id) }"
@click.stop="toggleParent(row.task.id)"
>{{ expandedParents.has(row.task.id) ? '▾' : '▸' }}</button>
<span class="task-parent-icon">📑</span>
<span class="task-title">{{ row.task.title }}</span>
<span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span>
<!-- 子进度徽章(done+cancelled / total) -->
<span class="sub-progress-badge" :title="$t('tasks.tree.progress')">{{ row.progress!.done }}/{{ row.progress!.total }}</span>
</div>
<div class="task-meta">
<span class="branch-tag" v-if="row.task.branch_name">
<span class="branch-icon"></span>{{ row.task.branch_name }}
</span>
<span class="task-date">{{ formatRelative(row.task.updated_at) }}</span>
<!-- 迷你进度条(渐变填充,宽度=完成子任务百分比) -->
<div class="mini-progress" :title="$t('tasks.tree.progress')">
<div class="mini-progress-fill" :style="{ width: parentPct(row) + '%' }"></div>
</div>
</div>
</div>
<div class="task-meta">
<span class="branch-tag" v-if="task.branch_name">
<span class="branch-icon"></span>{{ task.branch_name }}
</span>
<span class="task-date">{{ formatRelative(task.updated_at) }}</span>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)"></button>
<div v-if="quickMenuId === row.task.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="quickAdvance(row.task.id, 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="quickPriority(row.task.id, p.value)">
<span :class="p.cls"></span>{{ p.label }}
</button>
</div>
<div class="quick-menu-divider"></div>
<!-- F-260805:父任务快捷菜单添加子任务(预填父任务打开新建弹窗) -->
<button class="quick-menu-item" @click="openCreateModal(row.task)">
<span></span>{{ $t('tasks.addSubtask') }}
</button>
<div class="quick-menu-divider"></div>
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(row.task, row.children.length)">{{ $t('tasks.quickDelete') }}</button>
</div>
</div>
</div>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(task.id)"></button>
<div v-if="quickMenuId === task.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="quickAdvance(task.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }}
</button>
<!-- 子任务行(父展开时 v-show 渲染):缩进 + 左侧竖线引导线 + 行首圆点连接符
常规快捷操作与顶层一致;点击跳 /tasks/{child.id} -->
<div
v-for="child in row.children"
v-show="expandedParents.has(row.task.id)"
:key="child.id"
class="task-item task-item-child"
@click="router.push(`/tasks/${child.id}`)"
>
<div class="task-main">
<div class="task-title-row">
<span class="child-dot"></span>
<span class="task-title">{{ child.title }}</span>
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
</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="quickPriority(task.id, p.value)">
<span :class="p.cls"></span>{{ p.label }}
</button>
<div class="task-meta">
<span class="branch-tag" v-if="child.branch_name">
<span class="branch-icon"></span>{{ child.branch_name }}
</span>
<span class="task-date">{{ formatRelative(child.updated_at) }}</span>
</div>
</div>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(statusLabel(child.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)"></button>
<div v-if="quickMenuId === 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="quickAdvance(child.id, 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="quickPriority(child.id, p.value)">
<span :class="p.cls"></span>{{ p.label }}
</button>
</div>
<div class="quick-menu-divider"></div>
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(child)">{{ $t('tasks.quickDelete') }}</button>
</div>
<div class="quick-menu-divider"></div>
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(task)">{{ $t('tasks.quickDelete') }}</button>
</div>
</div>
</div>
<!-- 顶层任务无子(普通行) -->
<div
v-if="!row.isParent"
class="task-item"
@click="router.push(`/tasks/${row.task.id}`)"
>
<div class="task-main">
<div class="task-title-row">
<span class="task-title">{{ row.task.title }}</span>
<span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span>
</div>
<div class="task-meta">
<span class="branch-tag" v-if="row.task.branch_name">
<span class="branch-icon"></span>{{ row.task.branch_name }}
</span>
<span class="task-date">{{ formatRelative(row.task.updated_at) }}</span>
</div>
</div>
<div class="task-actions">
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)"></button>
<div v-if="quickMenuId === row.task.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="quickAdvance(row.task.id, 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="quickPriority(row.task.id, p.value)">
<span :class="p.cls"></span>{{ p.label }}
</button>
</div>
<div class="quick-menu-divider"></div>
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(row.task)">{{ $t('tasks.quickDelete') }}</button>
</div>
</div>
</div>
</template>
</div>
</section>
</template>
</div>
<!-- 分页器 -->
<Paginator
v-model:page="page"
v-model:pageSize="pageSize"
:total="totalTasks"
/>
<!-- F-260805 D5:列表页改一次性加载 + 前端组装树,移除真分页 Paginator(分页会割裂父/) -->
<!-- 新建任务模态框(统一样式,去内联 style) -->
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
<div class="modal-box">
<h3>{{ $t('tasks.modal.title') }}</h3>
<div class="modal-field">
<label>{{ $t('tasks.modal.project') }}</label>
<select v-model="newTaskProjectId">
<!-- F-260805:选择父任务后 project 锁定为该父所属项目(disabled),需先选回再切项目 -->
<select v-model="newTaskProjectId" :disabled="!!newTaskParentId" @change="newTaskParentId = ''">
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<!-- F-260805:父任务下拉(选项=当前选中项目的顶层任务 + 首项;提交透传 parent_id,=顶层任务) -->
<div class="modal-field">
<label>{{ $t('tasks.modal.parentTask') }}</label>
<select v-model="newTaskParentId">
<option value="">{{ $t('tasks.modal.parentPlaceholder') }}</option>
<option v-for="p in parentTaskOptions" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
</div>
<div class="modal-field">
<label>{{ $t('tasks.modal.titleField') }}</label>
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
@@ -178,7 +288,6 @@ import { formatRelative } from '@/utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
import { taskApi } from '@/api'
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import Paginator from '../components/Paginator.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef'
@@ -196,14 +305,28 @@ const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
const loading = ref(false)
// 分页(默认开启 20 条/页)
const page = usePersistedRef('tasks.page', 1)
const pageSize = usePersistedRef('tasks.pageSize', 20)
const totalTasks = ref(0)
// F-260805 D5:列表页一次性加载 + 前端组装树,移除真分页(page/pageSize/totalTasks 已删)。
// 分组折叠状态(localStorage 记忆)
const collapsedGroups = reactive(new Set<string>())
// F-260805:父任务展开/折叠状态(localStorage 记忆,沿用 collapsedGroups 模式)
const expandedParents = reactive(new Set<string>())
function toggleParent(id: string) {
if (expandedParents.has(id)) {
expandedParents.delete(id)
} else {
expandedParents.add(id)
}
localStorage.setItem('df-tasks-expanded', JSON.stringify([...expandedParents]))
}
// 恢复父任务展开记忆(脏数据静默忽略)
try {
const saved = localStorage.getItem('df-tasks-expanded')
if (saved) {
for (const id of JSON.parse(saved)) expandedParents.add(id)
}
} catch { /* 忽略脏数据 */ }
// 快捷菜单
const quickMenuId = ref<string | null>(null)
function toggleQuickMenu(id: string) {
@@ -237,9 +360,13 @@ async function quickPriority(id: string, priority: number) {
await store.loadTasks(buildTaskQuery())
} catch (e) { console.error('快捷改优先级失败:', e) }
}
async function quickDelete(task: TaskRecord) {
async function quickDelete(task: TaskRecord, childCount = 0) {
quickMenuId.value = null
if (!await confirmDialog(t('tasks.confirmDelete', { title: task.title }))) return
// F-260805:父任务带子任务时确认文案含子任务数(后端级联软删)
const msg = childCount > 0
? t('tasks.confirmDeleteWithChildren', { title: task.title, n: childCount })
: t('tasks.confirmDelete', { title: task.title })
if (!await confirmDialog(msg)) return
try {
await store.deleteTask(task.id)
} catch (e) { console.error('删除失败:', e) }
@@ -267,12 +394,8 @@ let _searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchKeyword, () => {
if (_searchTimer) clearTimeout(_searchTimer)
_searchTimer = setTimeout(() => {
page.value = 1
loading.value = true
Promise.all([
store.loadTasks(buildTaskQuery()),
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
]).finally(() => { loading.value = false })
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
}, 300)
})
@@ -283,14 +406,25 @@ const newTaskTitle = ref('')
const newTaskDesc = ref('')
const newTaskBranch = ref('')
const newTaskPriority = ref(2)
// F-260805:父任务下拉选中值(空串=无/顶层任务)
const newTaskParentId = ref('')
// F-260619-01:关联灵感(空串=不关联,与后端 idea_id 空串处理一致)
const newTaskIdeaId = ref('')
const submitting = ref(false)
interface TaskGroup {
projectName: string
icon: string
tasks: TaskRecord[]
// F-260805:父任务选项 = 当前选中项目的顶层任务(无 parent_id;仅顶层可作父,1 级嵌套)
const parentTaskOptions = computed(() =>
store.tasks.filter(t => !t.parent_id && t.project_id === newTaskProjectId.value),
)
// F-260805:树形行结构(父任务 + 直接子任务 + 子进度)。1 级嵌套,无递归。
interface TaskRow {
task: TaskRecord
/** 父任务的直接子任务(仅父有;叶子为空数组) */
children: TaskRecord[]
/** 父任务子进度(done+cancelled / total;叶子为 undefined) */
progress?: { done: number; total: number }
isParent: boolean
}
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
@@ -314,33 +448,70 @@ const projectIcons: Record<string, string> = {
'flux': '⚡',
}
const filteredGroups = computed(() => {
// 后端已分页(limit/offset),store.tasks 是当前页数据,直接分组无需再 slice
const sorted = [...store.tasks]
sorted.sort((a: any, b: any) => {
interface TaskGroup {
projectName: string
icon: string
/** 组内顶层任务行(子任务内嵌在行.children) */
rows: TaskRow[]
}
// F-260805:树组装 — 顶层 = 无 parent_id 的任务,每个顶层挂 children;父进度前端计算(D6,全量已在前端)。
const taskRows = computed<TaskRow[]>(() => {
// 子任务按 parent_id 分组(1 级嵌套)
const childMap = new Map<string, TaskRecord[]>()
for (const t of store.tasks) {
if (!t.parent_id) continue
const arr = childMap.get(t.parent_id) ?? []
arr.push(t)
childMap.set(t.parent_id, arr)
}
// 排序沿用现有 sortBy 逻辑(顶层 + 子级同一比较器)
const cmp = (a: TaskRecord, b: TaskRecord): number => {
if (sortBy.value === 'priority') return (a.priority ?? 2) - (b.priority ?? 2)
if (sortBy.value === 'status') return String(a.status).localeCompare(String(b.status))
const av = a[sortBy.value] ?? ''
const bv = b[sortBy.value] ?? ''
const av = (a as any)[sortBy.value] ?? ''
const bv = (b as any)[sortBy.value] ?? ''
return String(bv).localeCompare(String(av))
})
}
const tops = store.tasks.filter(t => !t.parent_id).sort(cmp)
const rows: TaskRow[] = []
for (const t of tops) {
const children = (childMap.get(t.id) ?? []).sort(cmp)
const isParent = children.length > 0
const done = children.filter(c => c.status === 'done' || c.status === 'cancelled').length
rows.push({
task: t,
children,
isParent,
progress: isParent ? { done, total: children.length } : undefined,
})
}
return rows
})
// 按项目分组
const groupMap = new Map<string, TaskRecord[]>()
for (const task of sorted) {
if (!groupMap.has(task.project_id)) {
groupMap.set(task.project_id, [])
/** 父任务迷你进度条百分比(done/total) */
function parentPct(row: TaskRow): number {
if (!row.progress || row.progress.total === 0) return 0
return Math.round((row.progress.done / row.progress.total) * 100)
}
const filteredGroups = computed(() => {
// F-260805 D5:store.tasks 是一次性拉取的筛选全量,前端已组装树(taskRows),直接按项目分组
const groupMap = new Map<string, TaskRow[]>()
for (const row of taskRows.value) {
if (!groupMap.has(row.task.project_id)) {
groupMap.set(row.task.project_id, [])
}
groupMap.get(task.project_id)!.push(task)
groupMap.get(row.task.project_id)!.push(row)
}
const result: TaskGroup[] = []
for (const [projectId, groupTasks] of groupMap) {
for (const [projectId, rows] of groupMap) {
const name = getProjectName(projectId)
result.push({
projectName: name,
icon: projectIcons[name] || '📂',
tasks: groupTasks,
rows,
})
}
return result
@@ -354,16 +525,23 @@ function buildTaskQuery(): TaskQuery | undefined {
if (!projAll) query.project_id = activeProject.value
if (!statusAll) query.status = activeStatus.value
if (kw) query.keyword = kw
// 后端真分页:传 limit/offset,配合 count_by_query 获取 total
if (pageSize.value > 0) {
query.limit = pageSize.value
query.offset = (page.value - 1) * pageSize.value
}
// F-260805 D5:一次拉当前筛选全量(limit 放大 500 钳制上限,offset 恒 0),
// 树形需要完整父子关系,分页会割裂父/子;全量已在前端,父进度也前端计算
query.limit = 500
query.offset = 0
return query
}
function openCreateModal() {
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
// F-260805:打开新建弹窗。可选 parent(父任务快捷菜单「添加子任务」):
// 有父 → project_id 锁定为该父所属项目、parent_id 预填;无父(顶部「新建任务」)→ 默认父=无(顶层任务)。
function openCreateModal(parent?: TaskRecord) {
if (parent) {
newTaskParentId.value = parent.id
newTaskProjectId.value = parent.project_id
} else {
newTaskParentId.value = ''
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
}
newTaskTitle.value = ''
newTaskDesc.value = ''
newTaskBranch.value = ''
@@ -384,6 +562,8 @@ async function confirmCreate() {
priority: newTaskPriority.value,
// F-260619-01:空串=不关联(对齐后端 idea_id 空串语义)
idea_id: newTaskIdeaId.value.trim() || undefined,
// F-260805:父任务透传(空串=顶层任务,后端视为 None)
parent_id: newTaskParentId.value || undefined,
})
if (!r) return
showCreateModal.value = false
@@ -395,40 +575,19 @@ async function confirmCreate() {
// 筛选切换重载
watch(activeProject, () => {
store.setActiveTaskProject(activeProject.value)
page.value = 1
loading.value = true
Promise.all([
store.loadTasks(buildTaskQuery()),
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
]).finally(() => { loading.value = false })
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
watch(activeStatus, () => {
store.setActiveTaskStatus(activeStatus.value)
page.value = 1
loading.value = true
Promise.all([
store.loadTasks(buildTaskQuery()),
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
]).finally(() => { loading.value = false })
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
watch(sortBy, () => {
page.value = 1
loading.value = true
Promise.all([
store.loadTasks(buildTaskQuery()),
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
]).finally(() => { loading.value = false })
})
// 分页翻页/改每页条数:重新查询后端
watch([page, pageSize], () => {
loading.value = true
Promise.all([
store.loadTasks(buildTaskQuery()),
taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n),
]).finally(() => { loading.value = false })
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
// Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键)
@@ -449,10 +608,10 @@ onMounted(async () => {
try {
await Promise.all([
store.loadProjects(),
store.loadTasks(),
// F-260805 D5:一次拉当前筛选全量(limit 500),树形需要完整父子关系
store.loadTasks(buildTaskQuery()),
// F-260619-01:加载灵感供新建任务模态关联下拉选择(幂等,已加载则 no-op)
store.loadIdeas(),
taskApi.count().then(n => totalTasks.value = n),
])
} finally {
loading.value = false
@@ -546,7 +705,7 @@ onUnmounted(() => {
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
}
.group-header:hover { background: rgba(108, 99, 255, 0.04); }
.group-header:hover { background: var(--df-accent-bg); }
.task-group.collapsed .group-header { border-bottom: none; }
.group-chevron { font-size: 10px; color: var(--df-text-dim); width: 12px; text-align: center; }
.group-icon { font-size: 16px; }
@@ -574,7 +733,7 @@ onUnmounted(() => {
cursor: pointer;
}
.task-item:last-child { border-bottom: none; }
.task-item:hover { background: rgba(108, 99, 255, 0.04); }
.task-item:hover { background: var(--df-accent-bg); }
.task-main { flex: 1; min-width: 0; }
@@ -649,6 +808,66 @@ onUnmounted(() => {
.task-item:hover .task-quick-btn { opacity: 0.6; }
.task-quick-btn:hover { opacity: 1; }
/* ===== F-260805 父子任务树形 ===== */
/* 折叠箭头(仅父任务行,点击只切展开不跳详情) */
.fold-btn {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
color: var(--df-text-dim);
width: 12px;
padding: 0;
flex-shrink: 0;
transition: color 0.15s;
}
.fold-btn:hover { color: var(--df-accent); }
/* 父任务图标(与子任务区分) */
.task-parent-icon {
font-size: 13px;
flex-shrink: 0;
}
/* 子进度徽章 n/m(父任务行) */
.sub-progress-badge {
font-size: 10px;
font-weight: 600;
color: var(--df-text-dim);
background: rgba(255,255,255,0.06);
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
white-space: nowrap;
}
/* 迷你进度条(细条渐变填充,宽度=完成子任务百分比) */
.mini-progress {
width: 72px;
height: 4px;
background: var(--df-border);
border-radius: var(--df-radius-xs);
overflow: hidden;
flex-shrink: 0;
}
.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;
}
/* 子任务行:缩进 + 左侧竖线引导线(延续父任务) */
.task-item-child {
padding-left: 32px;
border-left: 0.5px solid var(--df-border);
margin-left: 20px;
}
/* 行首圆点连接符(子任务) */
.child-dot {
font-size: 10px;
color: var(--df-accent);
flex-shrink: 0;
width: 8px;
text-align: center;
}
.quick-menu {
position: absolute;
top: calc(100% + 4px);