1007 lines
39 KiB
Vue
1007 lines
39 KiB
Vue
<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>
|