Files
DevFlow/src/views/Tasks.vue
T

1007 lines
39 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="tasks">
<!-- 顶部工具栏:标题 + 搜索 + 新建 -->
<header class="page-header">
<h1>{{ $t('tasks.title') }}</h1>
<div class="header-actions">
<input
v-model="searchKeyword"
class="search-input"
:placeholder="$t('tasks.searchPlaceholder')"
@keyup.esc="searchKeyword = ''"
/>
<button class="btn btn-primary" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
</div>
</header>
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 筛选栏(紧凑下拉) -->
<div class="filter-bar">
<div class="filter-group">
<span class="filter-label">{{ $t('tasks.filter.project') }}</span>
<select v-model="activeProject" class="filter-select">
<option value="all">{{ $t('tasks.filter.all') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">{{ $t('tasks.filter.status') }}</span>
<div class="filter-chip-group">
<button
v-for="s in statusFilters"
:key="s.key"
class="filter-chip"
:class="{ 'is-active': activeStatuses.length === 0 || activeStatuses.includes(s.key) }"
@click="toggleStatus(s.key)"
>
{{ s.icon }} {{ s.label }}
</button>
</div>
</div>
<div class="filter-group">
<span class="filter-label">{{ $t('tasks.sort.label') }}</span>
<select v-model="sortBy" class="filter-select">
<option value="updated_at">{{ $t('tasks.sort.updated') }}</option>
<option value="created_at">{{ $t('tasks.sort.created') }}</option>
<option value="priority">{{ $t('tasks.sort.priority') }}</option>
<option value="status">{{ $t('tasks.sort.status') }}</option>
</select>
</div>
<span class="filter-count">{{ $t('common.pagination.total', { n: store.tasks.length }) }}</span>
</div>
<!-- 任务分组列表 -->
<div class="task-groups">
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
<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>
</div>
<template v-else>
<section
v-for="group in filteredGroups"
:key="group.projectName"
class="task-group"
:class="{ collapsed: collapsedGroups.has(group.projectName) }"
>
<div class="group-header" @click="toggleGroup(group.projectName)">
<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.rows.length }}</span>
</div>
<div v-show="!collapsedGroups.has(group.projectName)" class="task-list">
<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);WK-11:搜索态隐藏(树形进度失真) -->
<span v-if="!isSearching" 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>
<!-- 迷你进度条(渐变填充,宽度=完成子任务百分比);WK-11:搜索态隐藏 -->
<div v-if="!isSearching" class="mini-progress" :title="$t('tasks.tree.progress')">
<div class="mini-progress-fill" :style="{ width: parentPct(row) + '%' }"></div>
</div>
</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 quickStatusesFor(row.task)" :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>
<!-- 子任务行(父展开时 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="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 quickStatusesFor(child)" :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>
</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 quickStatusesFor(row.task)" :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>
<!-- 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>
<!-- 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" />
</div>
<div class="modal-field">
<label>{{ $t('tasks.modal.desc') }}</label>
<input v-model="newTaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('tasks.modal.branch') }}</label>
<input v-model="newTaskBranch" :placeholder="$t('tasks.modal.branchPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('tasks.modal.priority') }}</label>
<select v-model="newTaskPriority">
<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>
<!-- F-260619-01:关联灵感(1对1 单向,可选)空串=不关联,与后端/AI 工具层一致 -->
<div class="modal-field">
<label>{{ $t('tasks.modal.idea') }}</label>
<select v-model="newTaskIdeaId">
<option value="">{{ $t('tasks.modal.ideaPlaceholder') }}</option>
<option v-for="idea in store.ideas" :key="idea.id" :value="idea.id">{{ idea.title }}</option>
</select>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button>
</div>
</div>
</div>
<!-- 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) -->
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
<!-- 确认弹层(删除任务,替代原生 window.confirm) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { formatRelative } from '@/utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass, TASK_STATUS_TRANSITIONS } from '../constants/project'
import { taskApi } from '@/api'
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef'
import { useToast } from '@/composables/useToast'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// WK-13:快捷操作失败 toast 反馈(对齐 Projects.vue useToast 模式)
const { toast, showToast } = useToast()
// 列表筛选/分页状态持久化到 localStorage(key 前缀 'tasks.'),
// 刷新页面后保留用户上次选择。collapsedGroups 单独走 df-tasks-collapsed(沿用既有实现)。
const activeProject = usePersistedRef('tasks.activeProject', 'all')
const activeStatuses = usePersistedRef<string[]>('tasks.activeStatuses', [])
// 状态多选切换
function toggleStatus(key: string) {
const idx = activeStatuses.value.indexOf(key)
if (idx >= 0) {
activeStatuses.value.splice(idx, 1)
} else {
activeStatuses.value.push(key)
}
}
const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
const loading = ref(false)
// WK-11:搜索态(keyword 过滤可能截断父子关系,子进度徽章/迷你条语义失真 → 隐藏)
const isSearching = computed(() => searchKeyword.value.trim().length > 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) {
quickMenuId.value = quickMenuId.value === id ? null : id
}
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') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
])
/** WK-8:快捷状态目标 = 状态机合法流转目标(TASK_STATUS_TRANSITIONS 过滤,对齐 TaskDetail ADVANCE_MAP) */
function quickStatusesFor(task: TaskRecord): { key: string; icon: string; label: string }[] {
const valid = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
return quickStatuses.value.filter(s => valid.has(s.key))
}
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' },
])
async function quickAdvance(id: string, target: string) {
quickMenuId.value = null
try {
await taskApi.advance(id, target)
await store.loadTasks(buildTaskQuery())
} catch (e: any) {
// WK-13:快捷操作失败 toast 反馈(原 console.error 静默,用户无感知)
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
}
async function quickPriority(id: string, priority: number) {
quickMenuId.value = null
try {
// WK-13:改用 taskApi 直调(不走 store.updateTask,避免失败置 store.error 触发整表错误态)
await taskApi.update(id, 'priority', String(priority))
await store.loadTasks(buildTaskQuery())
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
}
async function quickDelete(task: TaskRecord, childCount = 0) {
quickMenuId.value = null
// 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 {
// WK-13:deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
const ok = await store.deleteTask(task.id)
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
} catch (e) { console.error('删除失败:', e) }
}
// 点击外部关闭快捷菜单
function closeQuickMenu() { quickMenuId.value = null }
function toggleGroup(name: string) {
if (collapsedGroups.has(name)) {
collapsedGroups.delete(name)
} else {
collapsedGroups.add(name)
}
localStorage.setItem('df-tasks-collapsed', JSON.stringify([...collapsedGroups]))
}
// 恢复折叠记忆
try {
const saved = localStorage.getItem('df-tasks-collapsed')
if (saved) {
for (const name of JSON.parse(saved)) collapsedGroups.add(name)
}
} catch { /* 忽略脏数据 */ }
// 搜索防抖
let _searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchKeyword, () => {
// WK-10:登记当前关键字到 barrel(数据变更联动刷新复用同一筛选,不冲掉搜索结果)
store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined)
if (_searchTimer) clearTimeout(_searchTimer)
_searchTimer = setTimeout(() => {
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
}, 300)
})
// ── 新建任务模态框 ──
const showCreateModal = ref(false)
const newTaskProjectId = ref('')
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)
// 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 }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
{ key: 'in_review', label: t('tasks.statusFilter.in_review'), icon: '👀' },
{ key: 'testing', label: t('tasks.statusFilter.testing'), icon: '🧪' },
{ key: 'done', label: t('tasks.statusFilter.done'), icon: '✅' },
{ key: 'blocked', label: t('tasks.statusFilter.blocked'), icon: '🚫' },
{ key: 'cancelled', label: t('tasks.statusFilter.cancelled'), icon: '🗑️' },
])
function getProjectName(projectId: string): string {
return store.projects.find(p => p.id === projectId)?.name ?? t('tasks.group.unknownProject')
}
const projectIcons: Record<string, string> = {
'u-ask': '🤖',
'u-img': '🖼️',
'flux': '⚡',
}
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 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
})
/** 父任务迷你进度条百分比(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 statusFilter = (row: TaskRow) =>
activeStatuses.value.length === 0 || activeStatuses.value.includes(row.task.status)
const groupMap = new Map<string, TaskRow[]>()
for (const row of taskRows.value) {
if (!statusFilter(row)) continue
if (!groupMap.has(row.task.project_id)) {
groupMap.set(row.task.project_id, [])
}
groupMap.get(row.task.project_id)!.push(row)
}
const result: TaskGroup[] = []
for (const [projectId, rows] of groupMap) {
const name = getProjectName(projectId)
result.push({
projectName: name,
icon: projectIcons[name] || '📂',
rows,
})
}
return result
})
function buildTaskQuery(): TaskQuery | undefined {
const projAll = activeProject.value === 'all'
const kw = searchKeyword.value.trim()
const query: TaskQuery = { order_by: sortBy.value }
if (!projAll) query.project_id = activeProject.value
// 状态多选走前端过滤(后端 status 单值,不给后端传使全量拉取,前端 filteredGroups 过滤)
if (kw) query.keyword = kw
// F-260805 D5:一次拉当前筛选全量(limit 放大 500 钳制上限,offset 恒 0),
// 树形需要完整父子关系,分页会割裂父/子;全量已在前端,父进度也前端计算
query.limit = 500
query.offset = 0
return query
}
// 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 = ''
newTaskPriority.value = 2
newTaskIdeaId.value = ''
showCreateModal.value = true
}
async function confirmCreate() {
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
submitting.value = true
try {
const r = await store.createTask({
project_id: newTaskProjectId.value as ProjectId,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
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
} finally {
submitting.value = false
}
}
// 筛选切换重载
watch(activeProject, () => {
store.setActiveTaskProject(activeProject.value)
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
watch(activeStatuses, () => {
store.setActiveTaskStatus(activeStatuses.value.length === 0 ? 'all' : activeStatuses.value.join(','))
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
watch(sortBy, () => {
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
// Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键)
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault()
openCreateModal()
} else if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
document.querySelector<HTMLInputElement>('.search-input')?.focus()
}
}
onMounted(async () => {
store.setActiveTaskProject(activeProject.value)
store.setActiveTaskStatus(activeStatuses.value.length === 0 ? 'all' : activeStatuses.value.join(','))
store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined)
loading.value = true
try {
await Promise.all([
store.loadProjects(),
// F-260805 D5:一次拉当前筛选全量(limit 500),树形需要完整父子关系
store.loadTasks(buildTaskQuery()),
// F-260619-01:加载灵感供新建任务模态关联下拉选择(幂等,已加载则 no-op)
store.loadIdeas(),
])
} finally {
loading.value = false
}
window.addEventListener('keydown', onKeydown)
document.addEventListener('click', closeQuickMenu)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.removeEventListener('click', closeQuickMenu)
if (_searchTimer) clearTimeout(_searchTimer)
})
</script>
<style scoped>
.tasks { padding: 16px 20px 20px; }
/* page-header / page-header h1 / header-actions 已提取到全局 global.css */
/* 搜索框 */
.search-input {
width: 220px;
max-width: 100%;
flex: 1 1 220px;
padding: 6px 12px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--df-accent); }
.search-input::placeholder { color: var(--df-text-dim); }
/* btn / btn-primary / btn-ghost / btn-sm 已提取到全局 global.css */
/* 筛选栏(紧凑) */
/* filter-bar 已提取到全局 global.css,下方仅保留 Tasks 特有筛选类 */
.filter-group {
display: flex;
align-items: center;
gap: 6px;
}
.filter-label {
font-size: 12px;
color: var(--df-text-dim);
white-space: nowrap;
}
.filter-select {
padding: 4px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 12px;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
}
.filter-select:hover { border-color: var(--df-accent); }
.filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.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: 11px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.filter-chip:hover {
background: var(--df-bg-card);
color: var(--df-text);
border-color: var(--df-accent);
}
.filter-chip.is-active {
background: var(--df-accent-soft);
color: var(--df-accent);
border-color: var(--df-accent);
}
.filter-count {
margin-left: auto;
font-size: 12px;
color: var(--df-text-dim);
}
/* 任务分组 */
.task-groups {
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
.task-group {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
/* WK-9:原 overflow:hidden 会把快捷菜单(.quick-menu 绝对定位向下溢出)裁切。
改为组自身不裁切,圆角裁剪下放给 header(顶角)与末行(底角,见下),菜单可正常浮出。 */
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
/* WK-9:承接原 .task-group overflow:hidden 的顶角圆角裁剪(自身 overflow,组不再裁切快捷菜单) */
border-radius: var(--df-radius-lg) var(--df-radius-lg) 0 0;
overflow: hidden;
}
.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; }
.group-name { font-size: 14px; font-weight: 500; color: var(--df-text); flex: 1; }
.group-count {
font-size: 11px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.06);
padding: 1px 8px;
border-radius: 8px;
}
/* 任务条目(紧凑) */
.task-list {
display: flex;
flex-direction: column;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
border-bottom: 0.5px solid rgba(255,255,255,0.03);
transition: background 0.1s;
cursor: pointer;
}
.task-item:last-child { border-bottom: none; }
/* WK-9:末行承接组底角圆角裁剪(替代原 .task-group overflow:hidden) */
.task-item:last-child { border-radius: 0 0 var(--df-radius-lg) var(--df-radius-lg); }
.task-item:hover { background: var(--df-accent-bg); }
.task-main { flex: 1; min-width: 0; }
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.priority-badge {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
flex-shrink: 0;
}
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
.priority-high { background: rgba(255,152,0,0.2); color: #ff9800; }
.priority-medium { background: rgba(100,181,246,0.2); color: var(--df-info); }
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.task-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 2px;
}
.branch-tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--df-text-dim);
}
.task-date {
font-size: 11px;
color: var(--df-text-dim);
}
/* status-tag 尺寸/圆角 + status-* 7 态色已提取到全局 components.css(WK-6 对齐三视图),此处不再重复定义 */
/* 快捷操作 */
.task-actions {
display: flex;
align-items: center;
gap: 6px;
position: relative;
}
.task-quick-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
opacity: 0;
transition: opacity 0.15s;
}
.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);
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;
}
.quick-menu-danger { color: var(--df-danger); }
/* ===== 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) ===== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 200; max-width: 80vw;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; }
.toast-success { background: var(--df-success); color: #fff; }
/* 空态 */
/* empty-state 已提取到全局 global.css */
.empty-icon { font-size: 32px; opacity: 0.4; }
/* 模态框 overlay/box/modal-field/modal-actions 已提取到 global.css 全局 */
</style>