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

1459 lines
56 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" v-if="viewMode === 'list'">
<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': s.key === 'all' ? activeStatuses.length === 0 : activeStatuses.includes(s.key) }"
@click="toggleStatus(s.key)"
>
{{ s.icon }} {{ s.label }}
</button>
</div>
</div>
<div class="filter-group" v-if="viewMode === 'list'">
<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>
<!-- UX走查 P2-2: 计数对齐可见行口径——列表模式按状态筛选后的可见行数(batchScopeIds,
含折叠父的子任务),看板模式各状态列恒全显仍取全量;原固定 store.tasks.length,
勾选状态芯片后计数不变与可见行数矛盾 -->
<span class="filter-count">{{ $t('common.pagination.total', { n: viewMode === 'list' ? batchScopeIds.length : store.tasks.length }) }}</span>
<!-- 列表/看板切换(持久化) -->
<div class="filter-group view-toggle">
<button class="seg-btn" :class="{ 'is-active': viewMode === 'list' }" @click="viewMode = 'list'">{{ $t('tasks.kanban.toggleList') }}</button>
<button class="seg-btn" :class="{ 'is-active': viewMode === 'kanban' }" @click="viewMode = 'kanban'">{{ $t('tasks.kanban.toggleKanban') }}</button>
</div>
<!-- 列表模式全选(看板无批量栏,隐藏) -->
<label v-if="viewMode === 'list'" class="select-all" :title="$t('tasks.batch.selectHint')">
<input
type="checkbox"
:checked="selection.allSelected"
:indeterminate.prop="selection.someSelected"
@change="selection.toggleAll(($event.target as HTMLInputElement).checked)"
/>
{{ $t('tasks.batch.selectAll') }}
</label>
</div>
<!-- 任务分组列表(列表模式) -->
<div class="task-groups" v-if="viewMode === 'list'">
<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}`)"
>
<input
type="checkbox"
class="task-checkbox"
:checked="selection.isSelected(row.task.id)"
@click.stop
@change="selection.toggle(row.task.id, ($event.target as HTMLInputElement).checked)"
/>
<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}`)"
>
<input
type="checkbox"
class="task-checkbox"
:checked="selection.isSelected(child.id)"
@click.stop
@change="selection.toggle(child.id, ($event.target as HTMLInputElement).checked)"
/>
<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}`)"
>
<input
type="checkbox"
class="task-checkbox"
:checked="selection.isSelected(row.task.id)"
@click.stop
@change="selection.toggle(row.task.id, ($event.target as HTMLInputElement).checked)"
/>
<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(分页会割裂父/子) -->
<!-- 任务看板:始终渲染,用 CSS 隐藏/显示(非 display:none,Sortable 需可见 DOM 初始化) -->
<div
class="kanban-board"
ref="kanbanBoardRef"
:class="{ 'is-busy': kanban.kanbanBusy, 'is-loading': viewMode === 'kanban' && loading, 'is-hidden': viewMode !== 'kanban' }"
>
<!-- 看板加载指示(UX走查 P2-4):看板常驻渲染,切项目/搜索期间旧筛选卡片仍在展示,
轻提示 + 卡片降透明度避免误读为最新数据;局部于看板容器,非全局遮罩,文案对齐列表模式 -->
<div v-if="viewMode === 'kanban' && loading" class="kanban-loading">{{ $t('common.loading') }}</div>
<div v-for="col in kanbanColumns" :key="col.status" class="kanban-col" :data-status="col.status">
<div class="kanban-col-header">
<span class="kanban-col-name">{{ $t(statusLabel(col.status)) }}</span>
<span class="kanban-col-count">{{ col.tasks.length }}</span>
</div>
<div class="kanban-col-body" :data-status="col.status">
<div
v-for="task in col.tasks"
:key="task.id"
class="task-card"
:class="{ 'task-card--parent': parentIdSet.has(task.id) }"
:data-task-id="task.id"
:title="parentIdSet.has(task.id) ? $t('tasks.kanban.parentNoDrag') : undefined"
>
<div class="task-card-top">
<span class="task-card-title" @click.stop="openDrawer(task.id)">{{ task.title }}</span>
</div>
<div class="task-card-meta">
<span v-if="task.parent_id" class="parent-tag" :title="parentTitle(task.parent_id)">⤷ {{ parentTitle(task.parent_id) }}</span>
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<span v-if="task.assignee" class="assignee-tag">{{ task.assignee }}</span>
</div>
</div>
<div v-if="col.tasks.length === 0" class="kanban-empty">{{ $t('tasks.kanban.emptyColumn') }}</div>
</div>
</div>
</div>
<!-- 批量操作栏(选中任务后浮现;看板模式无批量) -->
<TaskBatchBar
v-if="viewMode === 'list' && selection.count > 0"
:count="selection.count"
:busy="batch.busy"
:advance-targets="batchAdvanceTargets"
:cancel-available="batchAvail.cancel"
:defer-available="batchAvail.defer"
:resume-available="batchAvail.resume"
:cascade-estimate="batchCascadeEstimate"
:toast="batch.toast"
:assignee-suggestions="assigneeSuggestions"
@advance="batch.advanceMany([...selection.selected], $event)"
@cancel="batch.cancelMany([...selection.selected])"
@defer="batch.deferMany([...selection.selected])"
@resume="batch.resumeMany([...selection.selected])"
@priority="batch.updateMany([...selection.selected], 'priority', $event)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event)"
@delete="batch.deleteMany([...selection.selected])"
@clear="selection.clear()"
/>
<!-- 新建任务模态框(统一样式,去内联 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>
<!-- 创建失败内联错误条(UX走查 P1):红字条放表单下方,弹窗不关可直接改后重试 -->
<div v-if="createError" class="modal-error">{{ createError }}</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" />
<!-- 任务详情抽屉(看板卡片点击弹出) -->
<TaskDrawer
:task-id="drawerTaskId"
:visible="drawerVisible"
@close="closeDrawer()"
@advance-done="store.loadTasks(buildTaskQuery())"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, reactive, nextTick } 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, TASK_STATUS_ORDER } from '../constants/project'
import { taskApi } from '@/api'
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import TaskDrawer from '@/components/task/TaskDrawer.vue'
import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef'
import { useToast } from '@/composables/useToast'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import { useTaskKanban } from '@/composables/task/useTaskKanban'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 快捷操作失败 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', [])
// 列表/看板视图切换(持久化;看板模式隐藏状态多选与排序,列即状态)
const viewMode = usePersistedRef<'list' | 'kanban'>('tasks.viewMode', 'list')
// 状态多选切换:点击「全部」清空数组(空=未筛选=显示全部);
// 点击具体状态时移除「全部」残留(已有具体筛选则「全部」芯片不应保留激活)。
function toggleStatus(key: string) {
if (key === 'all') {
activeStatuses.value = []
return
}
const idx = activeStatuses.value.indexOf(key)
if (idx >= 0) {
activeStatuses.value.splice(idx, 1)
} else {
// 有具体状态筛选时,清掉「全部」残留(两者互斥)
const allIdx = activeStatuses.value.indexOf('all')
if (allIdx >= 0) activeStatuses.value.splice(allIdx, 1)
activeStatuses.value.push(key)
}
}
const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
const loading = ref(false)
// 搜索态(keyword 过滤可能截断父子关系,子进度徽章/迷你条语义失真 → 隐藏)
const isSearching = computed(() => searchKeyword.value.trim().length > 0)
// F-260805 D5:列表页一次性加载 + 前端组装树,移除真分页(page/pageSize/totalTasks 已删)。
// 分组折叠状态(localStorage 记忆)
const collapsedGroups = reactive(new Set<string>())
// 父任务展开/折叠状态(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
}
/** 状态图标+文案键(排序由 TASK_STATUS_ORDER 统一驱动,看板列/快捷菜单/状态多选共用) */
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
todo: { icon: '📝', labelKey: 'tasks.statusFilter.todo' },
in_progress: { icon: '🔨', labelKey: 'tasks.statusFilter.in_progress' },
in_review: { icon: '👀', labelKey: 'tasks.statusFilter.in_review' },
testing: { icon: '🧪', labelKey: 'tasks.statusFilter.testing' },
done: { icon: '✅', labelKey: 'tasks.statusFilter.done' },
blocked: { icon: '🚫', labelKey: 'tasks.statusFilter.blocked' },
deferred: { icon: '⏰', labelKey: 'tasks.statusFilter.deferred' },
cancelled: { icon: '🗑️', labelKey: 'tasks.statusFilter.cancelled' },
}
const quickStatuses = computed(() =>
TASK_STATUS_ORDER.map(key => ({ key, icon: STATUS_FILTER_META[key].icon, label: t(STATUS_FILTER_META[key].labelKey) })),
)
/** 快捷状态目标 = 状态机合法流转目标(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) {
// 快捷操作失败 toast 反馈(原 console.error 静默,用户无感知)
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
}
async function quickPriority(id: string, priority: number) {
quickMenuId.value = null
try {
// 改用 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
// 父任务带子任务时确认文案含子任务数(后端级联软删)
const msg = childCount > 0
? t('tasks.confirmDeleteWithChildren', { title: task.title, n: childCount })
: t('tasks.confirmDelete', { title: task.title })
if (!await confirmDialog(msg)) return
try {
// deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
const ok = await store.deleteTask(task.id)
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
} catch (e) {
// UX走查 P3-3: throw 型异常原仅 console.error 静默,用户无感知;对齐 quickAdvance/quickPriority 的 toast 反馈
console.error('删除失败:', e)
showToast(e?.toString() ?? t('tasks.err.deleteFailed'), 'error')
}
}
// 点击外部关闭快捷菜单
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, () => {
// 登记当前关键字到 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)
// 父任务下拉选中值(空串=无/顶层任务)
const newTaskParentId = ref('')
// 关联灵感(空串=不关联,与后端 idea_id 空串处理一致)
const newTaskIdeaId = ref('')
const submitting = ref(false)
// 新建失败内联错误(UX走查 2026-10-03 P1):store.error 横幅渲染在页面流,被 modal-overlay
// 全屏遮罩盖住不可见,失败时弹窗必须自带红字条反馈,且保持弹窗不关让用户改完重试
const createError = ref('')
// 父任务选项 = 当前选中项目的顶层任务(无 parent_id;仅顶层可作父,1 级嵌套)
const parentTaskOptions = computed(() =>
store.tasks.filter(t => !t.parent_id && t.project_id === newTaskProjectId.value),
)
// 树形行结构(父任务 + 直接子任务 + 子进度)。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: '📋' },
...TASK_STATUS_ORDER.map(key => ({ key, label: t(STATUS_FILTER_META[key].labelKey), icon: STATUS_FILTER_META[key].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[]
}
// 树组装 — 顶层 = 无 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
})
// ── 批量操作(勾选范围 = 当前筛选可见全部行,含折叠父的子任务,v-show 已在 DOM) ──
const batchScopeIds = computed(() => {
const ids: string[] = []
for (const g of filteredGroups.value) {
for (const row of g.rows) {
ids.push(row.task.id)
for (const c of row.children) ids.push(c.id)
}
}
return ids
})
// reactive 包装:模板属性(count/allSelected/busy/kanbanRenderKey 等)自动解包,避免每处 .value
const selection = reactive(useTaskBatchSelection({ scopeIds: batchScopeIds }))
const batch = reactive(useTaskBatchActions({
getTask: id => store.tasks.find(t => t.id === id),
onRefresh: () => store.loadTasks(buildTaskQuery()),
}))
// 可见范围一变(筛选/搜索/排序/项目切换/刷新)就剪掉屏幕外的幽灵选中,
// 一处收敛所有漂移源,避免批量删除误伤不可见任务。
watch(batchScopeIds, () => { selection.prune() })
/** 批量推进目标 = 选中集各任务合法目标的交集(全部可达才展示,点了就能全成功);含 done 则交集空 */
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
let common: Set<string> | null = null
for (const id of ids) {
const task = store.tasks.find(t => t.id === id)
if (!task) continue
const legal = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
if (common === null) {
common = legal
} else {
const next = new Set<string>()
for (const s of common) if (legal.has(s)) next.add(s)
common = next
}
if (common.size === 0) return []
}
return quickStatuses.value.filter(s => common?.has(s.key))
})
/** 恢复类动作可用状态(deferred/cancelled→todo、blocked→in_progress,对齐 useTaskBatchActions.RESUME_TARGET) */
const RESUME_STATES = new Set(['deferred', 'cancelled', 'blocked'])
const batchAvail = computed(() => {
let cancel = false
let defer = false
let resume = false
for (const id of selection.selected) {
const task = store.tasks.find(t => t.id === id)
if (!task) continue
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('cancelled')) cancel = true
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('deferred')) defer = true
if (RESUME_STATES.has(task.status)) resume = true
}
return { cancel, defer, resume }
})
/** 删除确认文案的级联子任务数 = 选中父任务的未选中直接子任务数(选中的子随父级联,不再单独计数) */
const batchCascadeEstimate = computed(() => {
const selected = selection.selected
let n = 0
for (const id of selected) {
const task = store.tasks.find(t => t.id === id)
if (!task || task.parent_id) continue
for (const c of store.tasks) {
if (c.parent_id === id && !selected.has(c.id)) n++
}
}
return n
})
/** 指派建议 = 现有任务 assignee 去重 */
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const t of store.tasks) if (t.assignee) set.add(t.assignee)
return [...set]
})
// ── 看板(列表/看板切换,Sortable 跨列拖拽推进) ──
const kanban = reactive(useTaskKanban({
onRefresh: () => store.loadTasks(buildTaskQuery()),
}))
const kanbanBoardRef = ref<HTMLElement | null>(null)
const kanbanColumns = computed(() =>
TASK_STATUS_ORDER.map(status => ({
status,
tasks: store.tasks.filter(t => t.status === status),
})),
)
// 被任何任务引为 parent 的 id 集:看板里这些是父卡,禁拖(父状态=子聚合,不可直接推进)。
const parentIdSet = computed(() => {
const set = new Set<string>()
for (const t of store.tasks) if (t.parent_id) set.add(t.parent_id)
return set
})
function parentTitle(pid: string): string {
return store.tasks.find(t => t.id === pid)?.title ?? ''
}
function kanbanRefresh() {
nextTick(() => {
if (kanbanBoardRef.value) kanban.setupKanbanSortable(kanbanBoardRef.value)
})
}
// 任务详情抽屉(看板卡片点击弹出)
const drawerTaskId = ref<string | null>(null)
const drawerVisible = computed(() => drawerTaskId.value !== null)
function openDrawer(id: string) { drawerTaskId.value = id }
function closeDrawer() { drawerTaskId.value = null }
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
}
// 打开新建弹窗。可选 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 = ''
// 每次打开清掉上一次的失败残留
createError.value = ''
showCreateModal.value = true
}
async function confirmCreate() {
if (!newTaskProjectId.value) { showToast(t('tasks.err.selectProject'), 'error'); return }
if (!newTaskTitle.value.trim()) return
submitting.value = true
createError.value = ''
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,
// 空串=不关联(对齐后端 idea_id 空串语义)
idea_id: newTaskIdeaId.value.trim() || undefined,
// 父任务透传(空串=顶层任务,后端视为 None)
parent_id: newTaskParentId.value || undefined,
})
if (!r) {
// 失败:弹窗不关,内联透出错误(store.error 横幅在遮罩之下看不到;取具体错误,无则兜底文案)
createError.value = store.error || t('tasks.err.createFailed')
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.key === 'Escape' && showCreateModal.value) {
showCreateModal.value = false
return
}
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()
}
}
// 看板生命周期:requestAnimationFrame+setTimeout 双重保底,确保 DOM 渲染后初始化 Sortable
watch(viewMode, v => {
if (v === 'kanban') {
selection.clear()
window.requestAnimationFrame(() => {
setTimeout(() => {
if (kanbanBoardRef.value) kanban.setupKanbanSortable(kanbanBoardRef.value)
}, 100)
})
} else {
kanban.destroyKanban()
}
})
// 数据源变化 → 重新初始化 Sortable
watch(() => store.tasks, () => {
if (viewMode.value === 'kanban' && kanbanBoardRef.value) {
window.requestAnimationFrame(() => {
if (kanbanBoardRef.value) kanban.setupKanbanSortable(kanbanBoardRef.value)
})
}
})
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()),
// 加载灵感供新建任务模态关联下拉选择(幂等,已加载则 no-op)
store.loadIdeas(),
])
} finally {
loading.value = false
}
if (viewMode.value === 'kanban') kanbanRefresh()
window.addEventListener('keydown', onKeydown)
document.addEventListener('click', closeQuickMenu)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.removeEventListener('click', closeQuickMenu)
if (_searchTimer) clearTimeout(_searchTimer)
kanban.destroyKanban()
})
</script>
<style scoped>
.tasks { padding: 16px 20px 20px; }
/* 新建任务失败内联错误条(UX走查 P1):页面流 error-banner 被 modal 遮罩盖住,弹窗内自带红字条 */
.modal-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-sm);
background: var(--df-danger-bg, rgba(220, 38, 38, 0.08));
color: var(--df-danger, #dc2626);
font-size: 12px;
}
/* 列表/看板切换分段按钮 */
.view-toggle { margin-left: auto; display: flex; gap: 0; }
.seg-btn {
padding: 4px 12px;
font-size: 12px;
color: var(--df-text-dim);
background: var(--df-bg);
border: 0.5px solid var(--df-border);
cursor: pointer;
transition: all 0.15s;
}
.seg-btn:first-child { border-radius: var(--df-radius-sm) 0 0 var(--df-radius-sm); }
.seg-btn:last-child { border-radius: 0 var(--df-radius-sm) var(--df-radius-sm) 0; }
.seg-btn.is-active { color: var(--df-accent); background: var(--df-accent-bg); border-color: var(--df-accent); }
/* 全选 */
.select-all {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
white-space: nowrap;
user-select: none;
}
/* 行勾选 checkbox(点击行跳详情,checkbox 已 @click.stop 拦截) */
.task-checkbox {
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: var(--df-accent);
cursor: pointer;
margin: 0;
}
/* ══ 任务看板 ══ */
.kanban-board {
display: flex;
gap: 10px;
align-items: flex-start;
overflow-x: auto;
padding-bottom: 8px;
position: relative; /* 承载局部加载浮标(UX走查 P2-4) */
}
/* 加载浮标:局部于看板容器的轻提示,压在列上方居中,不阻断列布局;pointer-events none 不挡拖拽热区 */
.kanban-loading {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
padding: 3px 14px;
border-radius: 999px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
color: var(--df-text-dim);
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
/* 加载期间旧筛选卡片降透明度+禁交互,避免被误读为当前筛选的最新结果 */
.kanban-board.is-loading .task-card { opacity: 0.45; pointer-events: none; }
.kanban-board.is-busy .task-card { pointer-events: none; }
.kanban-board.is-hidden { display: none; }
.kanban-col {
flex: 1 0 220px;
min-width: 220px;
max-width: 280px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 220px);
}
.kanban-col-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.kanban-col-icon { font-size: 13px; }
.kanban-col-name { font-size: 13px; font-weight: 500; color: var(--df-text); flex: 1; }
.kanban-col-count {
font-size: 11px;
color: var(--df-text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 1px 8px;
border-radius: 8px;
}
.kanban-col-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
min-height: 120px;
overflow-y: auto;
}
.kanban-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
font-size: 12px;
color: var(--df-text-dim);
border: 1px dashed var(--df-border);
border-radius: var(--df-radius-sm);
}
.task-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 8px 10px;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
}
.task-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); border-color: var(--df-accent); }
.task-card.sortable-ghost { opacity: 0.5; }
/* 父卡禁拖:去掉抓取光标、左侧描边提示这是聚合容器卡 */
.task-card--parent { cursor: default; border-left: 2px solid var(--df-accent); }
.task-card-top { display: flex; align-items: flex-start; gap: 6px; }
.task-card-title {
flex: 1;
font-size: 13px;
color: var(--df-text);
line-height: 1.4;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-card-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.parent-tag {
font-size: 11px;
color: var(--df-text-dim);
background: rgba(255, 255, 255, 0.05);
padding: 1px 6px;
border-radius: 6px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assignee-tag {
font-size: 11px;
color: var(--df-text-dim);
border: 0.5px solid var(--df-border);
padding: 1px 6px;
border-radius: 6px;
}
/* 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);
/* 原 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;
/* 承接原 .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; }
/* 末行承接组底角圆角裁剪(替代原 .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;
}
/* 四档统一 color-mix 派生自语义 token(对齐 high 档既有写法,主题切换语义不变) */
.priority-critical { background: color-mix(in srgb, var(--df-danger) 20%, transparent); color: var(--df-danger); }
.priority-high { background: color-mix(in srgb, var(--df-warning) 20%, transparent); color: var(--df-warning); }
.priority-medium { background: color-mix(in srgb, var(--df-info) 20%, transparent); color: var(--df-info); }
.priority-low { background: color-mix(in srgb, var(--df-text-dim) 20%, transparent); 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 {
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;
}
/* 子任务行:缩进 + 左侧竖线引导线(延续父任务) */
.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>