修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复
This commit is contained in:
+129
-45
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user