修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复

This commit is contained in:
lxy
2026-08-12 00:29:06 +08:00
parent 9407f821b9
commit d31a27512c
36 changed files with 2614 additions and 264 deletions
+147 -12
View File
@@ -294,6 +294,16 @@
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label 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.filter.all') }}
</label>
</div>
<div class="task-filter-chips">
<button
@@ -313,6 +323,14 @@
:key="task.id"
@click="router.push('/tasks/' + task.id)"
>
<div class="task-card-checkbox">
<input
type="checkbox"
:checked="selection.isSelected(task.id)"
@click.stop
@change="selection.toggle(task.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div class="task-top">
<span class="task-title">{{ task.title }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
@@ -329,6 +347,27 @@
</div>
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<!-- 批量操作栏(选中任务后浮现) -->
<TaskBatchBar
v-if="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).then(afterBatch)"
@cancel="batch.cancelMany([...selection.selected]).then(afterBatch)"
@defer="batch.deferMany([...selection.selected]).then(afterBatch)"
@resume="batch.resumeMany([...selection.selected]).then(afterBatch)"
@priority="batch.updateMany([...selection.selected], 'priority', $event).then(afterBatch)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event).then(afterBatch)"
@delete="batch.deleteMany([...selection.selected]).then(afterBatch)"
@clear="selection.clear()"
/>
</section>
</div>
@@ -423,7 +462,7 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, reactive, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
@@ -435,13 +474,16 @@ import { moduleApi, type ProjectModuleRecord } from '@/api/module'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
import { projectStatusLabel, taskStatusLabel, taskStatusClass, TASK_STATUS_TRANSITIONS, TASK_STATUS_ORDER } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
@@ -588,16 +630,20 @@ async function loadProjectTasks() {
}
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
// 任务状态多选过滤
const taskStatusFilters = computed(() => [
{ key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') },
{ key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') },
{ key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') },
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
])
// 任务状态多选过滤(排序由 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 taskStatusFilters = computed(() =>
TASK_STATUS_ORDER.map(key => ({ key, icon: STATUS_FILTER_META[key].icon, label: t(STATUS_FILTER_META[key].labelKey) })),
)
const activeTaskStatuses = ref<string[]>([])
function toggleTaskStatus(key: string) {
const idx = activeTaskStatuses.value.indexOf(key)
@@ -612,6 +658,68 @@ const filteredProjectTasks = computed(() => {
return projectTasks.value.filter(t => activeTaskStatuses.value.includes(t.status))
})
// ── 任务批量操作(本项目任务列表);reactive 包装使模板属性自动解包 ──
const selection = reactive(useTaskBatchSelection({
scopeIds: computed(() => filteredProjectTasks.value.map(t => t.id)),
}))
const batch = reactive(useTaskBatchActions({
getTask: id => projectTasks.value.find(t => t.id === id),
onRefresh: () => loadProjectTasks(),
}))
async function afterBatch() {
selection.prune()
}
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 = projectTasks.value.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 taskStatusFilters.value.filter(s => common?.has(s.key))
})
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 = projectTasks.value.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 = projectTasks.value.find(t => t.id === id)
if (!task || task.parent_id) continue
for (const c of projectTasks.value) {
if (c.parent_id === id && !selected.has(c.id)) n++
}
}
return n
})
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const t of projectTasks.value) if (t.assignee) set.add(t.assignee)
return [...set]
})
// ── 工程列表(概览内 CRUD)──
// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认)
// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。
@@ -1400,6 +1508,8 @@ onUnmounted(() => {
color: var(--df-accent);
border-color: var(--df-accent);
}
/* 已暂缓状态 badge */
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
/* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签) */
.global-event-tag {
@@ -1413,6 +1523,31 @@ onUnmounted(() => {
/* ===== 任务卡片 ===== */
.task-list { display: flex; flex-direction: column; }
/* 批量操作:全选 + 行勾选(checkbox 绝对定位,卡片内容右移避开) */
.select-all {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.select-all input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.task-card { position: relative; }
.task-card-checkbox {
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: flex;
}
.task-card-checkbox input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.task-top, .task-branch, .task-meta { padding-left: 18px; }
.task-card {
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
+129 -45
View File
@@ -144,6 +144,16 @@
<div class="panel-header">
<h2>{{ $t('taskDetail.childrenTitle') }}</h2>
<span class="subtask-count">{{ $t('taskDetail.subtaskCount', { n: children.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label v-if="children.length > 0" 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.filter.all') }}
</label>
</div>
<!-- 顶部进度条 + 计数(done+cancelled / total) -->
<div v-if="children.length > 0" class="subtask-progress">
@@ -159,6 +169,13 @@
class="subtask-item"
@click="router.push(`/tasks/${child.id}`)"
>
<input
type="checkbox"
class="subtask-checkbox"
:checked="selection.isSelected(child.id)"
@click.stop
@change="selection.toggle(child.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="subtask-title">{{ child.title }}</span>
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
@@ -184,6 +201,27 @@
<!-- 空态:父任务无子任务 -->
<div v-else class="empty-hint subtask-empty">{{ $t('taskDetail.childEmpty') }}</div>
<button class="btn btn-ghost btn-sm subtask-add" type="button" @click="openSubtaskModal">{{ $t('taskDetail.addSubtask') }}</button>
<!-- 批量操作栏(选中子任务后浮现) -->
<TaskBatchBar
v-if="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).then(afterBatch)"
@cancel="batch.cancelMany([...selection.selected]).then(afterBatch)"
@defer="batch.deferMany([...selection.selected]).then(afterBatch)"
@resume="batch.resumeMany([...selection.selected]).then(afterBatch)"
@priority="batch.updateMany([...selection.selected], 'priority', $event).then(afterBatch)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event).then(afterBatch)"
@delete="batch.deleteMany([...selection.selected]).then(afterBatch)"
@clear="selection.clear()"
/>
</section>
</div>
@@ -245,7 +283,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { ref, computed, reactive, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
@@ -259,10 +297,17 @@ import {
taskStatusClass,
priorityLabel,
priorityClass,
TASK_STATUS_TRANSITIONS,
TASK_STATUS_ORDER,
ADVANCE_MAP,
} from '../constants/project'
import type { AdvanceAction } from '../constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
const { t } = useI18n()
const route = useRoute()
@@ -293,15 +338,21 @@ function toggleChildMenu(id: string) {
}
function closeChildMenu() { childMenuId.value = null }
// 复用列表页 quickStatuses/quickPriorities 模式(子任务行快捷推进)
const quickStatuses = computed(() => [
{ key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') },
{ key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') },
{ key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') },
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
])
// 复用列表页 quickStatuses/quickPriorities 模式(子任务行快捷推进)。
// 基础列表 = TASK_STATUS_ORDER 全 8 态(含 deferred/cancelled,快捷菜单经 ADVANCE_MAP 过滤后只显合法项)。
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) })),
)
/** 子任务快捷推进目标 = 状态机合法下一态(ADVANCE_MAP 过滤,与顶部手动按钮同源) */
function quickStatusesFor(child: TaskRecord) {
const valid = new Set((ADVANCE_MAP[child.status] ?? []).map((a) => a.target))
@@ -333,6 +384,58 @@ async function loadChildren() {
}
}
// ── 子任务批量操作;reactive 包装使模板属性自动解包 ──
const selection = reactive(useTaskBatchSelection({
scopeIds: computed(() => children.value.map(c => c.id)),
}))
const batch = reactive(useTaskBatchActions({
getTask: id => children.value.find(c => c.id === id),
onRefresh: () => loadChildren(),
}))
async function afterBatch() {
selection.prune()
}
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 = children.value.find(c => c.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))
})
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 = children.value.find(c => c.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 }
})
// 子任务均为叶子(无孙任务),级联删除数为 0
const batchCascadeEstimate = computed(() => 0)
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const c of children.value) if (c.assignee) set.add(c.assignee)
return [...set]
})
/** 取父任务标题(父面包屑用;取不到时回退显示父 id) */
async function loadParentTitle() {
const cur = task.value
@@ -555,41 +658,6 @@ const ideaTitle = computed(() => {
// testing → done(完成), in_review(退回重审), blocked, cancelled
// blocked → in_progress(恢复), cancelled
// done/cancelled → 终态,无按钮
// variant: primary=前向推进主操作(每态至多一个),danger=取消,ghost=其余(阻塞/退回/恢复)
interface AdvanceAction {
target: string
label: string // i18n key(走 taskDetail.advance.*)
variant: string // btn-primary | btn-danger | btn-ghost
}
const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
todo: [
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_progress: [
{ target: 'in_review', label: 'taskDetail.advance.toInReview', variant: 'btn-primary' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_review: [
{ target: 'testing', label: 'taskDetail.advance.toTesting', variant: 'btn-primary' },
{ target: 'in_progress', label: 'taskDetail.advance.sendBack', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
testing: [
{ target: 'done', label: 'taskDetail.advance.toDone', variant: 'btn-primary' },
{ target: 'in_review', label: 'taskDetail.advance.sendBackToReview', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
blocked: [
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
// done / cancelled:终态,无推进按钮
}
const advanceActions = computed<AdvanceAction[]>(() => {
const s = task.value?.status
if (!s) return []
@@ -812,6 +880,7 @@ onBeforeUnmount(() => {
/* D-260616-01:7 态新增 — testing(橙警告,近 review 区分)/blocked(红 danger,与 cancelled 区分用实心边框)。配色与 Tasks.vue 一致保持跨视图统一 */
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
.priority-badge {
font-size: 10px; font-weight: 500;
@@ -958,6 +1027,21 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
}
/* 子任务批量勾选 + 全选 */
.subtask-checkbox { flex-shrink: 0; width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; margin: 0; }
.select-all {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.select-all input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.subtask-item {
display: flex;
align-items: center;
+420 -24
View File
@@ -29,21 +29,21 @@
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="filter-group">
<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': activeStatuses.length === 0 || activeStatuses.includes(s.key) }"
: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">
<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>
@@ -53,10 +53,25 @@
</select>
</div>
<span class="filter-count">{{ $t('common.pagination.total', { n: 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">
<!-- 任务分组列表(列表模式) -->
<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>
@@ -86,6 +101,13 @@
: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
@@ -147,6 +169,13 @@
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>
@@ -189,6 +218,13 @@
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>
@@ -230,6 +266,63 @@
</div>
<!-- F-260805 D5:列表页改一次性加载 + 前端组装树,移除真分页 Paginator(分页会割裂父/子) -->
<!-- 任务看板:始终渲染,用 CSS 隐藏/显示(非 display:none,Sortable 需可见 DOM 初始化) -->
<div
class="kanban-board"
ref="kanbanBoardRef"
:class="{ 'is-busy': kanban.kanbanBusy, 'is-hidden': viewMode !== 'kanban' }"
>
<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">
@@ -290,22 +383,35 @@
<!-- 确认弹层(删除任务,替代原生 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 } from 'vue'
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 } from '../constants/project'
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()
@@ -318,13 +424,23 @@ const { toast, showToast } = useToast()
// 刷新页面后保留用户上次选择。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)
}
}
@@ -361,15 +477,20 @@ const quickMenuId = ref<string | null>(null)
function toggleQuickMenu(id: string) {
quickMenuId.value = quickMenuId.value === id ? null : id
}
const quickStatuses = computed(() => [
{ key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') },
{ key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') },
{ key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') },
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
])
/** 状态图标+文案键(排序由 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) })),
)
/** WK-8:快捷状态目标 = 状态机合法流转目标(TASK_STATUS_TRANSITIONS 过滤,对齐 TaskDetail ADVANCE_MAP) */
function quickStatusesFor(task: TaskRecord): { key: string; icon: string; label: string }[] {
const valid = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
@@ -474,13 +595,7 @@ interface TaskRow {
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
{ key: 'in_review', label: t('tasks.statusFilter.in_review'), icon: '👀' },
{ key: 'testing', label: t('tasks.statusFilter.testing'), icon: '🧪' },
{ key: 'done', label: t('tasks.statusFilter.done'), icon: '✅' },
{ key: 'blocked', label: t('tasks.statusFilter.blocked'), icon: '🚫' },
{ key: 'cancelled', label: t('tasks.statusFilter.cancelled'), icon: '🗑️' },
...TASK_STATUS_ORDER.map(key => ({ key, label: t(STATUS_FILTER_META[key].labelKey), icon: STATUS_FILTER_META[key].icon })),
])
function getProjectName(projectId: string): string {
@@ -566,6 +681,117 @@ const filteredGroups = computed(() => {
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()
@@ -649,6 +875,28 @@ function onKeydown(e: KeyboardEvent) {
}
}
// 看板生命周期: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(','))
@@ -665,6 +913,7 @@ onMounted(async () => {
} finally {
loading.value = false
}
if (viewMode.value === 'kanban') kanbanRefresh()
window.addEventListener('keydown', onKeydown)
document.addEventListener('click', closeQuickMenu)
})
@@ -673,12 +922,159 @@ onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.removeEventListener('click', closeQuickMenu)
if (_searchTimer) clearTimeout(_searchTimer)
kanban.destroyKanban()
})
</script>
<style scoped>
.tasks { padding: 16px 20px 20px; }
/* 列表/看板切换分段按钮 */
.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;
}
.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 */
/* 搜索框 */