- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰) - 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch - TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重 - 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible - UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认) - 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化 - 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs - 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传 - crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
1541 lines
61 KiB
Vue
1541 lines
61 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()" :aria-label="$t('common.close')">✕</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>
|
||
<!-- 归档箱 P1:开关态独立于 list/kanban,开启后整页只渲染归档任务 -->
|
||
<button class="seg-btn" :class="{ 'is-active': archiveBoxMode }" @click="toggleArchiveBox">{{ $t('tasks.archiveBox') }}</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">{{ archiveBoxMode ? '📦' : '📋' }}</div>
|
||
<p v-if="archiveBoxMode">{{ $t('tasks.archiveBoxEmpty') }}</p>
|
||
<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"
|
||
role="button"
|
||
tabindex="0"
|
||
:aria-expanded="!collapsedGroups.has(group.projectName)"
|
||
@click="toggleGroup(group.projectName)"
|
||
@keydown.enter.prevent="toggleGroup(group.projectName)"
|
||
@keydown.space.prevent="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')" :aria-label="$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')" :aria-label="$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')" :aria-label="$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" @click="quickArchive(row.task, true)">{{ $t('tasks.quickArchive') }}</button>
|
||
<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"
|
||
role="button"
|
||
tabindex="0"
|
||
@click.stop="openDrawer(task.id)"
|
||
@keydown.enter.prevent="openDrawer(task.id)"
|
||
@keydown.space.prevent="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, useRoute } 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 route = useRoute()
|
||
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')
|
||
// 归档功能 P1:归档箱开关(持久化)。开启后整页只渲染归档任务(独立数据源,不经 buildTaskQuery)。
|
||
const archiveBoxMode = usePersistedRef('tasks.archiveBoxMode', false)
|
||
// 归档箱数据源(仅归档箱模式拉取;列表态保持 null 不请求)
|
||
const archivedTasks = ref<TaskRecord[]>([])
|
||
|
||
// 状态多选切换:点击「全部」清空数组(空=未筛选=显示全部);
|
||
// 点击具体状态时移除「全部」残留(已有具体筛选则「全部」芯片不应保留激活)。
|
||
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')
|
||
}
|
||
}
|
||
// 归档功能 P1:快捷归档(archive=true 归档,false=归档箱内取消归档)。
|
||
// 归档后任务退出主视图(后端活跃查询恒过滤),本地从当前数据源移除;归档箱内取消则从归档箱移除并回主列表。
|
||
async function quickArchive(task: TaskRecord, archived: boolean) {
|
||
quickMenuId.value = null
|
||
if (archived && !await confirmDialog(t('tasks.confirmArchive', { title: task.title }))) return
|
||
try {
|
||
const ok = await store.archiveTask(task.id, archived)
|
||
if (!ok) { showToast(t('tasks.err.archiveFailed'), 'error'); return }
|
||
showToast(archived ? t('tasks.archivedToast') : t('tasks.unarchivedToast'))
|
||
// 归档箱模式:取消归档后从归档箱移除;列表模式无需动 store(store.archiveTask 已移除)
|
||
if (archiveBoxMode.value) archivedTasks.value = archivedTasks.value.filter(x => x.id !== task.id)
|
||
} catch (e: any) {
|
||
showToast(e?.toString() ?? t('tasks.err.archiveFailed'), 'error')
|
||
}
|
||
}
|
||
|
||
// 归档箱开关:开启时拉归档数据源;关闭时按当前筛选重拉主列表。
|
||
// 看板模式进入归档箱自动切回列表(归档箱走列表渲染,看板列语义=状态,与归档正交)。
|
||
async function toggleArchiveBox() {
|
||
archiveBoxMode.value = !archiveBoxMode.value
|
||
quickMenuId.value = null
|
||
if (archiveBoxMode.value) {
|
||
if (viewMode.value === 'kanban') viewMode.value = 'list'
|
||
loading.value = true
|
||
try {
|
||
const pid = activeProject.value === 'all' ? undefined : activeProject.value
|
||
archivedTasks.value = await taskApi.listArchived(pid)
|
||
} catch (e: any) {
|
||
showToast(e?.toString() ?? t('tasks.err.loadFailed'), 'error')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
} else {
|
||
loading.value = true
|
||
try { await store.loadTasks(buildTaskQuery()) } finally { loading.value = false }
|
||
}
|
||
}
|
||
// 点击外部关闭快捷菜单
|
||
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,全量已在前端)。
|
||
// 归档功能 P1:归档箱模式数据源切 archivedTasks(独立拉取,不经 buildTaskQuery)。
|
||
const listSource = computed<TaskRecord[]>(() => (archiveBoxMode.value ? archivedTasks.value : store.tasks))
|
||
const taskRows = computed<TaskRow[]>(() => {
|
||
// 子任务按 parent_id 分组(1 级嵌套)
|
||
const childMap = new Map<string, TaskRecord[]>()
|
||
for (const t of listSource.value) {
|
||
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 = listSource.value.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 }
|
||
|
||
// 路由 query.taskId 驱动抽屉(AI Chat 消息任务 chip 点击跳转入口):
|
||
// 从 /tasks?taskId=xxx 进入时自动打开对应任务抽屉。同路由内再次跳转(已在 /tasks 页)
|
||
// 由 watch(route.query.taskId) 响应;跨路由进入由 onMounted 调用。URL 参数消费后清除,
|
||
// 避免刷新页面重复弹抽屉。
|
||
function applyRouteTaskId() {
|
||
const tid = route.query.taskId
|
||
if (typeof tid === 'string' && tid) {
|
||
openDrawer(tid)
|
||
// 消费后清除 query(taskId),保持 URL 干净;replace 不新增历史栈
|
||
const rest = { ...route.query }
|
||
delete rest.taskId
|
||
router.replace({ path: route.path, query: rest })
|
||
}
|
||
}
|
||
|
||
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()
|
||
// 路由 query.taskId 驱动抽屉(AI Chat chip 跳转入口)
|
||
applyRouteTaskId()
|
||
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: background 0.15s, color 0.15s, border-color 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); }
|
||
/* 聚焦可见环:仅 hover 有变化、聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环 */
|
||
.filter-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||
.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: background 0.15s, color 0.15s, border-color 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>
|