修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复
This commit is contained in:
+2
-2
@@ -147,11 +147,11 @@ export interface PromotionResult {
|
||||
// ============================================================
|
||||
|
||||
// TaskRecord.status union 类型 — 值集对齐后端 TaskStatus serde (df-types/src/types.rs:131)
|
||||
// snake_case: todo | in_progress | in_review | testing | done | blocked | cancelled
|
||||
// snake_case: todo | in_progress | in_review | testing | done | blocked | cancelled | deferred
|
||||
// 注:constants/project.ts 的 mapLegacyStatus 兜底老 DB 旧态(completed/review_ready/merged/abandoned),
|
||||
// 仅在 taskStatusLabel/taskStatusClass 辅助函数内映射,record.status 本身经后端 is_valid 拦截无旧态。
|
||||
export type TaskStatus =
|
||||
| 'todo' | 'in_progress' | 'in_review' | 'testing' | 'done' | 'blocked' | 'cancelled'
|
||||
| 'todo' | 'in_progress' | 'in_review' | 'testing' | 'done' | 'blocked' | 'cancelled' | 'deferred'
|
||||
|
||||
export interface TaskRecord {
|
||||
id: TaskId
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="task-batch-bar" v-if="count > 0">
|
||||
<div class="tbb-info">
|
||||
<span class="tbb-count">{{ $t('tasks.batch.selected', { n: count }) }}</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="$emit('clear')">{{ $t('tasks.batch.clear') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="tbb-actions">
|
||||
<!-- 推进到 ▾(仅渲染全部可达的交集目标) -->
|
||||
<div class="tbb-menu" ref="advanceMenuRef">
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy || advanceTargets.length === 0" @click.stop="toggleMenu('advance')">
|
||||
{{ $t('tasks.batch.advanceTo') }} ▾
|
||||
</button>
|
||||
<div v-if="activeMenu === 'advance'" class="tbb-dropdown" @click.stop>
|
||||
<div v-if="advanceTargets.length === 0" class="tbb-dropdown-empty">{{ $t('tasks.batch.noCommonTarget') }}</div>
|
||||
<button v-for="s in advanceTargets" :key="s.key" class="tbb-dropdown-item" @click="pick('advance', s.key)">
|
||||
<span>{{ s.icon }}</span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy || !cancelAvailable" @click="$emit('cancel')">{{ $t('tasks.batch.cancel') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy || !deferAvailable" @click="$emit('defer')">{{ $t('tasks.batch.defer') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy || !resumeAvailable" @click="$emit('resume')">{{ $t('tasks.batch.resume') }}</button>
|
||||
|
||||
<!-- 改优先级 ▾ -->
|
||||
<div class="tbb-menu" ref="priorityMenuRef">
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('priority')">
|
||||
{{ $t('tasks.batch.priority') }} ▾
|
||||
</button>
|
||||
<div v-if="activeMenu === 'priority'" class="tbb-dropdown" @click.stop>
|
||||
<button v-for="p in priorityOptions" :key="p.value" class="tbb-dropdown-item" @click="pick('priority', String(p.value))">
|
||||
<span :class="p.cls">●</span>{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指派 ▾(内嵌输入 + 建议) -->
|
||||
<div class="tbb-menu" ref="assigneeMenuRef">
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click.stop="toggleMenu('assignee')">
|
||||
{{ $t('tasks.batch.assignee') }} ▾
|
||||
</button>
|
||||
<div v-if="activeMenu === 'assignee'" class="tbb-dropdown tbb-dropdown--assignee" @click.stop>
|
||||
<input
|
||||
v-model="assigneeInput"
|
||||
class="tbb-assignee-input"
|
||||
:placeholder="$t('tasks.batch.assigneePlaceholder')"
|
||||
list="tbb-assignee-suggest"
|
||||
@keydown.enter="confirmAssignee"
|
||||
/>
|
||||
<datalist id="tbb-assignee-suggest">
|
||||
<option v-for="a in assigneeSuggestions" :key="a" :value="a" />
|
||||
</datalist>
|
||||
<button class="btn btn-primary btn-sm tbb-assignee-ok" :disabled="!assigneeInput.trim()" @click="confirmAssignee">
|
||||
{{ $t('common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="busy" class="tbb-spinner" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm tbb-danger" :disabled="busy" @click="confirmDelete">{{ $t('tasks.batch.delete') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- toast 反馈(批量动作结果) -->
|
||||
<Transition name="toast">
|
||||
<div v-if="toast.visible" class="tbb-toast" :class="'tbb-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除二次确认 -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="$t('common.delete')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import type { ToastState } from '@/composables/useToast'
|
||||
|
||||
export interface AdvanceTargetOption {
|
||||
key: string
|
||||
icon: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
count: number
|
||||
busy: boolean
|
||||
/** 交集合法推进目标(空 = 无可统一推进) */
|
||||
advanceTargets: AdvanceTargetOption[]
|
||||
cancelAvailable: boolean
|
||||
deferAvailable: boolean
|
||||
resumeAvailable: boolean
|
||||
/** 删除确认文案用的级联子任务数 */
|
||||
cascadeEstimate: number
|
||||
assigneeSuggestions?: string[]
|
||||
/** 批量动作的 toast 状态(来自 useTaskBatchActions),本组件统一渲染 */
|
||||
toast: ToastState
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
advance: [target: string]
|
||||
cancel: []
|
||||
defer: []
|
||||
resume: []
|
||||
priority: [value: string]
|
||||
assignee: [value: string]
|
||||
delete: []
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
const priorityOptions = 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' },
|
||||
])
|
||||
|
||||
// ---- 下拉展开态(点击外部关闭) ----
|
||||
const activeMenu = ref<'advance' | 'priority' | 'assignee' | ''>('')
|
||||
const assigneeInput = ref('')
|
||||
const advanceMenuRef = ref<HTMLElement | null>(null)
|
||||
const priorityMenuRef = ref<HTMLElement | null>(null)
|
||||
const assigneeMenuRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function toggleMenu(menu: 'advance' | 'priority' | 'assignee') {
|
||||
activeMenu.value = activeMenu.value === menu ? '' : menu
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const inside =
|
||||
advanceMenuRef.value?.contains(e.target as Node) ||
|
||||
priorityMenuRef.value?.contains(e.target as Node) ||
|
||||
assigneeMenuRef.value?.contains(e.target as Node)
|
||||
if (!inside) activeMenu.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
|
||||
function pick(kind: 'advance' | 'priority', value: string) {
|
||||
activeMenu.value = ''
|
||||
if (kind === 'advance') emit('advance', value)
|
||||
else emit('priority', value)
|
||||
}
|
||||
|
||||
function confirmAssignee() {
|
||||
const v = assigneeInput.value.trim()
|
||||
if (!v) return
|
||||
activeMenu.value = ''
|
||||
assigneeInput.value = ''
|
||||
emit('assignee', v)
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
const msg =
|
||||
props.cascadeEstimate > 0
|
||||
? t('tasks.batch.confirmDelete', { n: props.count, c: props.cascadeEstimate })
|
||||
: t('tasks.batch.confirmDeletePlain', { n: props.count })
|
||||
confirmDialog(msg, t('common.delete')).then(ok => {
|
||||
if (ok) emit('delete')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 批量操作栏:底部固定,居中对齐,选中任务时浮现 */
|
||||
.task-batch-bar {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
max-width: 92vw;
|
||||
}
|
||||
.tbb-info { display: flex; align-items: center; gap: 8px; }
|
||||
.tbb-count { font-size: 12px; color: var(--df-accent); white-space: nowrap; }
|
||||
.tbb-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.tbb-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: tbb-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes tbb-spin { to { transform: rotate(360deg); } }
|
||||
.tbb-danger {
|
||||
background: transparent;
|
||||
color: var(--df-danger);
|
||||
border: 0.5px solid rgba(240, 101, 101, 0.4);
|
||||
}
|
||||
.tbb-danger:hover { background: rgba(240, 101, 101, 0.12); }
|
||||
.tbb-danger:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* 下拉菜单(向上弹出) */
|
||||
.tbb-menu { position: relative; }
|
||||
.tbb-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 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: 110;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.tbb-dropdown--assignee { min-width: 220px; padding: 10px; }
|
||||
.tbb-dropdown-empty { font-size: 12px; color: var(--df-text-dim); padding: 4px 12px; }
|
||||
.tbb-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
color: var(--df-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tbb-dropdown-item:hover { background: var(--df-accent-bg); }
|
||||
.tbb-assignee-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--df-text);
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
.tbb-assignee-input:focus { border-color: var(--df-accent); }
|
||||
.tbb-assignee-ok { margin-top: 8px; width: 100%; }
|
||||
|
||||
/* toast(独立于操作栏定位,避免被栏遮挡) */
|
||||
.tbb-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 200;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
max-width: 80vw;
|
||||
}
|
||||
.tbb-toast--success { border-color: rgba(82, 196, 26, 0.5); color: #67c23a; }
|
||||
.tbb-toast--warning { border-color: rgba(230, 162, 60, 0.5); color: #e6a23c; }
|
||||
.tbb-toast--error { border-color: rgba(240, 101, 101, 0.5); color: var(--df-danger); }
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<Drawer
|
||||
:visible="visible"
|
||||
width="640px"
|
||||
:mask="true"
|
||||
:mask-closable="true"
|
||||
:esc-to-close="true"
|
||||
:footer="false"
|
||||
@close="$emit('close')"
|
||||
header-style="display:none"
|
||||
>
|
||||
<div class="task-drawer" v-if="task">
|
||||
<!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 -->
|
||||
<div class="dr-header">
|
||||
<div class="dr-header-left">
|
||||
<h1>{{ task.title }}</h1>
|
||||
<div class="dr-badges">
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||
<span v-if="task.review_rounds > 0" class="review-rounds-badge">{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}</span>
|
||||
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-header-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="reload">{{ $t('taskDetail.refresh') }}</button>
|
||||
<button
|
||||
v-for="act in advanceActions"
|
||||
:key="act.target"
|
||||
class="btn btn-sm"
|
||||
:class="act.variant"
|
||||
:disabled="advancing"
|
||||
@click="handleAdvance(act.target)"
|
||||
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
|
||||
<div v-if="task.description" class="description-wrap">
|
||||
<span class="value description ai-md" v-html="renderedDesc"></span>
|
||||
</div>
|
||||
<div v-else class="empty-hint">{{ $t('taskDetail.descEmpty') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- 关联信息 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
|
||||
<div class="task-info">
|
||||
<div v-if="task.parent_id" class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.parentTask') }}</span>
|
||||
<span class="value">
|
||||
<router-link :to="`/tasks/${task.parent_id}`" class="project-link" @click="$emit('close')">{{ parentTaskTitle || task.parent_id }}</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.project') }}</span>
|
||||
<span class="value">
|
||||
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link" @click="$emit('close')">{{ projectName }}</router-link>
|
||||
<span v-else>—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="task.idea_id" class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
|
||||
<span class="value">
|
||||
<router-link :to="`/ideas/${task.idea_id}`" class="project-link" @click="$emit('close')">{{ ideaTitle }}</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="task.branch_name" class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.branch') }}</span>
|
||||
<span class="value">
|
||||
<span class="branch-tag"><span class="branch-icon">⑂</span>{{ task.branch_name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="task.assignee" class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.assignee') }}</span>
|
||||
<span class="value">{{ task.assignee }}</span>
|
||||
</div>
|
||||
<div class="info-item timestamps">
|
||||
<span class="timestamp">{{ $t('taskDetail.createdAt') }}: {{ formatDate(task.created_at) }}</span>
|
||||
<span class="timestamp">{{ $t('taskDetail.updatedAt') }}: {{ formatDate(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 任务产出(折叠式) -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('taskDetail.output') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="outputExpanded = !outputExpanded">{{ outputExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
|
||||
</div>
|
||||
<div v-if="outputExpanded">
|
||||
<div v-if="!task.output_json" class="empty-hint">—</div>
|
||||
<TaskOutputCard v-else :output-json="task.output_json" />
|
||||
</div>
|
||||
<div v-else class="empty-hint">{{ task.output_json ? $t('taskDetail.expand') : '—' }}</div>
|
||||
</section>
|
||||
|
||||
<!-- 子任务面板(仅顶层任务,简化版:无批量操作) -->
|
||||
<section v-if="!task.parent_id" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('taskDetail.childrenTitle') }}</h2>
|
||||
<span class="subtask-count">{{ $t('taskDetail.subtaskCount', { n: children.length }) }}</span>
|
||||
</div>
|
||||
<div v-if="children.length > 0" class="subtask-progress">
|
||||
<div class="mini-progress">
|
||||
<div class="mini-progress-fill" :style="{ width: subtaskProgress.pct + '%' }"></div>
|
||||
</div>
|
||||
<span class="subtask-progress-text">{{ $t('taskDetail.progressTitle') }}: {{ subtaskProgress.done }}/{{ subtaskProgress.total }}</span>
|
||||
</div>
|
||||
<div v-if="children.length > 0" class="subtask-list">
|
||||
<div v-for="child in children" :key="child.id" class="subtask-item" @click="router.push(`/tasks/${child.id}`); $emit('close')">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint subtask-empty">{{ $t('taskDetail.childEmpty') }}</div>
|
||||
<button class="btn btn-ghost btn-sm subtask-add" @click="showSubtaskModal = true">{{ $t('taskDetail.addSubtask') }}</button>
|
||||
</section>
|
||||
|
||||
<!-- 工作流进度(抽屉内简化:仅显示推进提示文本,不渲染完整 DAG) -->
|
||||
<section v-if="wfSnap" class="panel wf-panel">
|
||||
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
|
||||
<div class="wf-progress">
|
||||
<span v-if="wfSnap.runningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfSnap.runningNode }) }}</span>
|
||||
<span v-else-if="wfSnap.doneCount > 0" class="wf-progress-count">{{ $t('taskDetail.workflowStepsProgress', { done: wfSnap.doneCount, total: wfSnap.totalNodes }) }}</span>
|
||||
<span v-if="wfSnap.result === 'completed'" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
|
||||
<span v-else-if="wfSnap.result === 'failed'" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<div v-else-if="loading" class="empty-hint loading-hint">{{ $t('taskDetail.loading') }}</div>
|
||||
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
|
||||
|
||||
<!-- 子任务新建弹窗 -->
|
||||
<div class="modal-overlay" v-if="showSubtaskModal" @click.self="showSubtaskModal = false">
|
||||
<div class="modal-box">
|
||||
<h3>{{ $t('taskDetail.addSubtask') }}</h3>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('taskDetail.title') }}</label>
|
||||
<input v-model="subtaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmSubtask" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('taskDetail.description') }}</label>
|
||||
<input v-model="subtaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('taskDetail.priority') }}</label>
|
||||
<select v-model="subtaskPriority">
|
||||
<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>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showSubtaskModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" @click="confirmSubtask" :disabled="subtaskSubmitting || !subtaskTitle.trim()">{{ $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Drawer } from '@arco-design/web-vue'
|
||||
import { taskApi, projectApi, ideaApi } from '@/api'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
|
||||
import type { AdvanceAction } from '@/constants/project'
|
||||
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
|
||||
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: string | null
|
||||
visible: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'advance-done': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMsg = ref('')
|
||||
const task = ref<TaskRecord | null>(null)
|
||||
const projects = ref<ProjectRecord[]>([])
|
||||
const ideas = ref<IdeaRecord[]>([])
|
||||
const children = ref<TaskRecord[]>([])
|
||||
const parentTaskTitle = ref<string | null>(null)
|
||||
const advancing = ref(false)
|
||||
const outputExpanded = ref(false)
|
||||
|
||||
// 子任务新建
|
||||
const showSubtaskModal = ref(false)
|
||||
const subtaskTitle = ref('')
|
||||
const subtaskDesc = ref('')
|
||||
const subtaskPriority = ref(2)
|
||||
const subtaskSubmitting = ref(false)
|
||||
|
||||
const advanceActions = computed<AdvanceAction[]>(() => {
|
||||
const s = task.value?.status
|
||||
if (!s) return []
|
||||
return ADVANCE_MAP[s] ?? []
|
||||
})
|
||||
|
||||
// 解析
|
||||
const projectName = computed(() => {
|
||||
if (!task.value) return ''
|
||||
return projects.value.find(p => p.id === task.value!.project_id)?.name ?? ''
|
||||
})
|
||||
const ideaTitle = computed(() => {
|
||||
if (!task.value?.idea_id) return ''
|
||||
return ideas.value.find(i => i.id === task.value!.idea_id)?.title ?? task.value!.idea_id
|
||||
})
|
||||
const { rendered } = useRendered(
|
||||
() => task.value?.description ?? '',
|
||||
)
|
||||
const renderedDesc = computed(() => rendered.value)
|
||||
const subtaskProgress = computed(() => {
|
||||
const total = children.value.length
|
||||
const done = children.value.filter(c => c.status === 'done' || c.status === 'cancelled').length
|
||||
return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0 }
|
||||
})
|
||||
|
||||
// 工作流进度(从 store 共享,读取最近一次执行进度)
|
||||
const wfSnap = computed(() => {
|
||||
try {
|
||||
return store.workflowProgress(null)
|
||||
} catch { return null }
|
||||
})
|
||||
|
||||
// 加载
|
||||
async function load() {
|
||||
if (!props.taskId) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const [t, ps, ideasList] = await Promise.all([
|
||||
taskApi.get(props.taskId),
|
||||
projectApi.list(),
|
||||
ideaApi.list(),
|
||||
])
|
||||
task.value = t
|
||||
projects.value = ps
|
||||
ideas.value = ideasList
|
||||
await loadChildren()
|
||||
await loadParentTitle()
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.toString() ?? t('common.unknownError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChildren() {
|
||||
const cur = task.value
|
||||
if (!cur || cur.parent_id) { children.value = []; return }
|
||||
try {
|
||||
children.value = await taskApi.list({ project_id: cur.project_id, parent_id: cur.id })
|
||||
} catch { children.value = [] }
|
||||
}
|
||||
|
||||
async function loadParentTitle() {
|
||||
const cur = task.value
|
||||
if (!cur?.parent_id) { parentTaskTitle.value = null; return }
|
||||
try {
|
||||
const p = await taskApi.get(cur.parent_id)
|
||||
parentTaskTitle.value = p.title
|
||||
} catch { parentTaskTitle.value = null }
|
||||
}
|
||||
|
||||
function reload() { load() }
|
||||
|
||||
// 推进
|
||||
async function handleAdvance(target: string) {
|
||||
if (!task.value || advancing.value) return
|
||||
advancing.value = true
|
||||
try {
|
||||
const updated = await taskApi.advance(task.value.id, target)
|
||||
task.value = updated
|
||||
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
|
||||
emit('advance-done')
|
||||
} catch (e: any) {
|
||||
showToast(e?.toString() ?? t('common.unknownError'), 'error')
|
||||
} finally {
|
||||
advancing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.taskId, (id) => {
|
||||
if (id) load()
|
||||
})
|
||||
|
||||
// 子任务新建
|
||||
async function confirmSubtask() {
|
||||
if (!task.value || !subtaskTitle.value.trim()) return
|
||||
subtaskSubmitting.value = true
|
||||
try {
|
||||
await taskApi.create({
|
||||
project_id: task.value.project_id,
|
||||
title: subtaskTitle.value.trim(),
|
||||
description: subtaskDesc.value.trim(),
|
||||
priority: subtaskPriority.value,
|
||||
parent_id: task.value.id,
|
||||
})
|
||||
showSubtaskModal.value = false
|
||||
subtaskTitle.value = ''
|
||||
subtaskDesc.value = ''
|
||||
await loadChildren()
|
||||
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
|
||||
} catch (e: any) {
|
||||
showToast(e?.toString() ?? t('common.unknownError'), 'error')
|
||||
} finally {
|
||||
subtaskSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-drawer { padding: 0; }
|
||||
.dr-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.dr-header-left { flex: 1; min-width: 0; }
|
||||
.dr-header-left h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dr-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.loading-hint { text-align: center; padding: 40px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 任务批量动作执行 — 四类动作(推进/改状态、取消·暂缓·恢复、删除、改优先级/指派)。
|
||||
*
|
||||
* 设计:后端零改动,前端串行循环复用现有单条 taskApi(advance 自带 CAS + can_transition +
|
||||
* 父聚合 + review_rounds;delete 级联软删;update 白名单含 priority/assignee、禁 status)。
|
||||
* 合法性预判走 `TASK_STATUS_TRANSITIONS` 单一真相源(与 TaskDetail ADVANCE_MAP 同源),
|
||||
* 非法项记 skipped(不算失败),后端二次校验兜底(防御选中后外部改动)。
|
||||
*
|
||||
* 每个动作内置 busy 置位 + 结束后统一 onRefresh + toast 汇总;调用方拿返回的
|
||||
* BatchActionResult 可自行展示详情,toast 已自动处理。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { taskApi } from '@/api/task'
|
||||
import type { TaskRecord } from '@/api/types'
|
||||
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
export interface BatchFailedItem {
|
||||
id: string
|
||||
title: string
|
||||
error: string
|
||||
}
|
||||
export interface BatchSkippedItem {
|
||||
id: string
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
export interface BatchActionResult {
|
||||
ok: number
|
||||
failed: BatchFailedItem[]
|
||||
/** 状态机非法被跳过(非失败,不打扰用户) */
|
||||
skipped: BatchSkippedItem[]
|
||||
/** deleteMany 级联软删的子任务数合计 */
|
||||
cascaded?: number
|
||||
}
|
||||
|
||||
/** 恢复类动作的目标态解析:deferred/cancelled→todo、blocked→in_progress、其余无恢复路径 */
|
||||
const RESUME_TARGET: Record<string, string> = {
|
||||
deferred: 'todo',
|
||||
cancelled: 'todo',
|
||||
blocked: 'in_progress',
|
||||
}
|
||||
|
||||
/** 推进类动作的结果文案 key(tasks 命名空间,非删除场景) */
|
||||
const LABEL_BY_KIND: Record<'advance' | 'update', string> = {
|
||||
advance: 'tasks.batch.advanceResult',
|
||||
update: 'tasks.batch.updateResult',
|
||||
}
|
||||
|
||||
export function useTaskBatchActions(opts: {
|
||||
/** 由 id 解析当前任务(标题/状态/父);解析不到视为已不存在记 failed */
|
||||
getTask: (id: string) => TaskRecord | undefined
|
||||
/** 操作完成后统一刷新(三视图各自:loadTasks / loadProjectTasks / loadChildren) */
|
||||
onRefresh?: () => Promise<void> | void
|
||||
toastDurationMs?: number
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { toast, showToast } = useToast(opts.toastDurationMs)
|
||||
const busy = ref(false)
|
||||
|
||||
function finalize(r: BatchActionResult, labelKey: string, durationMs?: number) {
|
||||
busy.value = false
|
||||
const { ok, failed, skipped } = r
|
||||
if (failed.length === 0 && skipped.length === 0) {
|
||||
const msg = r.cascaded != null ? t('tasks.batch.deleteResult', { n: ok, c: r.cascaded }) : t(labelKey, { n: ok })
|
||||
showToast(msg, 'success', durationMs)
|
||||
} else if (failed.length === 0) {
|
||||
showToast(`${t(labelKey, { n: ok })},${t('tasks.batch.skipped', { n: skipped.length })}`, 'warning', durationMs)
|
||||
} else {
|
||||
showToast(`${t(labelKey, { n: ok })},${t('tasks.batch.failed', { n: failed.length })}`, 'error', durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** 通用串行执行器:逐条 op,抛错=failed;skip 返回 reason 则跳过不进 API */
|
||||
async function run(
|
||||
ids: string[],
|
||||
op: (id: string, task: TaskRecord) => Promise<unknown>,
|
||||
skip: (task: TaskRecord) => string | null,
|
||||
labelKey: string,
|
||||
): Promise<BatchActionResult> {
|
||||
busy.value = true
|
||||
const result: BatchActionResult = { ok: 0, failed: [], skipped: [] }
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const task = opts.getTask(id)
|
||||
if (!task) {
|
||||
result.failed.push({ id, title: id, error: t('tasks.err.loadFailed') })
|
||||
continue
|
||||
}
|
||||
const reason = skip(task)
|
||||
if (reason) {
|
||||
result.skipped.push({ id, title: task.title, reason })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await op(id, task)
|
||||
result.ok++
|
||||
} catch (e: any) {
|
||||
result.failed.push({ id, title: task.title, error: e?.toString?.() ?? String(e) })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { if (opts.onRefresh) await opts.onRefresh() } catch {}
|
||||
finalize(result, labelKey)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 批量推进到统一目标态(取消/暂缓/恢复均经此) */
|
||||
function advanceMany(ids: string[], target: string): Promise<BatchActionResult> {
|
||||
return run(
|
||||
ids,
|
||||
id => taskApi.advance(id, target),
|
||||
task => (TASK_STATUS_TRANSITIONS[task.status]?.includes(target) ? null : t('tasks.batch.noCommonTarget')),
|
||||
LABEL_BY_KIND.advance,
|
||||
)
|
||||
}
|
||||
|
||||
function cancelMany(ids: string[]): Promise<BatchActionResult> {
|
||||
return advanceMany(ids, 'cancelled')
|
||||
}
|
||||
|
||||
function deferMany(ids: string[]): Promise<BatchActionResult> {
|
||||
return advanceMany(ids, 'deferred')
|
||||
}
|
||||
|
||||
/** 恢复:目标态随当前态解析(deferred/cancelled→todo、blocked→in_progress) */
|
||||
function resumeMany(ids: string[]): Promise<BatchActionResult> {
|
||||
return run(
|
||||
ids,
|
||||
(id, task) => taskApi.advance(id, RESUME_TARGET[task.status]),
|
||||
task => (RESUME_TARGET[task.status] ? null : t('tasks.batch.noCommonTarget')),
|
||||
LABEL_BY_KIND.advance,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除(级联软删)。父+子同选去重:子随父由后端级联软删,不重复删父的子;
|
||||
* 未选父时子任务独立删(仅删自身)。直调 taskApi.delete 而非 store 动作,
|
||||
* 避免单条失败污染 store.error 触发整表 error banner(对齐 Tasks.vue quickDelete 直调模式)。
|
||||
*/
|
||||
async function deleteMany(ids: string[]): Promise<BatchActionResult> {
|
||||
busy.value = true
|
||||
const selected = new Set(ids)
|
||||
const result: BatchActionResult = { ok: 0, failed: [], skipped: [], cascaded: 0 }
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const task = opts.getTask(id)
|
||||
if (!task) {
|
||||
result.failed.push({ id, title: id, error: t('tasks.err.loadFailed') })
|
||||
continue
|
||||
}
|
||||
if (task.parent_id && selected.has(task.parent_id)) continue // 子随父级联,跳过避免重复删
|
||||
try {
|
||||
const res = await taskApi.delete(id)
|
||||
if (res?.ok) {
|
||||
result.ok++
|
||||
result.cascaded = (result.cascaded ?? 0) + (res.cascaded ?? 0)
|
||||
} else {
|
||||
result.failed.push({ id, title: task.title, error: t('tasks.err.deleteFailed') })
|
||||
}
|
||||
} catch (e: any) {
|
||||
result.failed.push({ id, title: task.title, error: e?.toString?.() ?? String(e) })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (opts.onRefresh) await opts.onRefresh()
|
||||
finalize(result, 'tasks.batch.deleteResult')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 批量更新字段(priority / assignee;status 禁走此,一律 advance) */
|
||||
function updateMany(ids: string[], field: 'priority' | 'assignee', value: string): Promise<BatchActionResult> {
|
||||
return run(ids, id => taskApi.update(id, field, value), () => null, LABEL_BY_KIND.update)
|
||||
}
|
||||
|
||||
return { busy, toast, showToast, advanceMany, cancelMany, deferMany, resumeMany, deleteMany, updateMany }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 任务批量勾选状态 — Tasks / ProjectDetail / TaskDetail 子任务三处列表共用。
|
||||
*
|
||||
* 选中集为 `Set<string>`(整体替换触发响应式,对齐 Projects.vue 批量导入模式,不做原地 mutate)。
|
||||
* scopeIds = 当前可见/可勾选 id 范围(随筛选、刷新变化);全选覆盖该范围全部行。
|
||||
*
|
||||
* 父/子语义:勾选父任务只勾父自身,不级联勾子(删除时后端自动级联软删子,推进/改字段只作用于父)。
|
||||
*/
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
export interface TaskBatchSelection {
|
||||
/** 选中 id 集(响应式 Set,写操作一律整体替换) */
|
||||
selected: Ref<Set<string>>
|
||||
count: ComputedRef<number>
|
||||
/** 范围非空且全部勾选 */
|
||||
allSelected: ComputedRef<boolean>
|
||||
/** 有勾选但未全选(表头半选态) */
|
||||
someSelected: ComputedRef<boolean>
|
||||
isSelected: (id: string) => boolean
|
||||
toggle: (id: string, checked: boolean) => void
|
||||
toggleAll: (checked: boolean) => void
|
||||
clear: () => void
|
||||
/** 移除已不在 scopeIds 的 id(刷新/筛选后收敛幽灵选中),返回被清理数 */
|
||||
prune: () => number
|
||||
}
|
||||
|
||||
export function useTaskBatchSelection(opts: {
|
||||
/** 当前可见可勾选 id 范围 */
|
||||
scopeIds: ComputedRef<string[]>
|
||||
}): TaskBatchSelection {
|
||||
const selected = ref<Set<string>>(new Set())
|
||||
|
||||
const count = computed(() => selected.value.size)
|
||||
|
||||
const allSelected = computed(() => {
|
||||
const scope = opts.scopeIds.value
|
||||
return scope.length > 0 && scope.every(id => selected.value.has(id))
|
||||
})
|
||||
|
||||
const someSelected = computed(() => count.value > 0 && !allSelected.value)
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selected.value.has(id)
|
||||
}
|
||||
|
||||
function toggle(id: string, checked: boolean) {
|
||||
const next = new Set(selected.value)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
selected.value = checked ? new Set(opts.scopeIds.value) : new Set()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selected.value = new Set()
|
||||
}
|
||||
|
||||
function prune(): number {
|
||||
const scope = new Set(opts.scopeIds.value)
|
||||
let removed = 0
|
||||
const next = new Set<string>()
|
||||
for (const id of selected.value) {
|
||||
if (scope.has(id)) next.add(id)
|
||||
else removed++
|
||||
}
|
||||
if (removed > 0) selected.value = next
|
||||
return removed
|
||||
}
|
||||
|
||||
return { selected, count, allSelected, someSelected, isSelected, toggle, toggleAll, clear, prune }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ref } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { taskApi } from '@/api/task'
|
||||
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
export function useTaskKanban(opts: {
|
||||
onRefresh: () => Promise<void> | void
|
||||
toastDurationMs?: number
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { showToast } = useToast(opts.toastDurationMs)
|
||||
const kanbanBusy = ref(false)
|
||||
let s: Sortable[] = []
|
||||
let _dragId: string | null = null
|
||||
let _executing = false
|
||||
|
||||
function destroy() { s.forEach(x => x.destroy()); s = [] }
|
||||
|
||||
function setup(el: HTMLElement) {
|
||||
destroy()
|
||||
el.querySelectorAll('.kanban-col-body').forEach(body => {
|
||||
s.push(new Sortable(body as HTMLElement, {
|
||||
group: 'kanban',
|
||||
draggable: '.task-card',
|
||||
// 父卡禁拖:父状态=子聚合派生,拖动会被状态机强写,违反容器模型。
|
||||
filter: '.kanban-empty, .task-card--parent',
|
||||
forceFallback: true,
|
||||
onMove: (e) => {
|
||||
const from = (e.from as HTMLElement).dataset.status ?? ''
|
||||
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status ?? ''
|
||||
if (from === to) return true
|
||||
return TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
|
||||
},
|
||||
onStart: (e) => {
|
||||
_dragId = (e.item as HTMLElement).dataset.taskId ?? null
|
||||
_executing = false
|
||||
},
|
||||
onEnd: async (e) => {
|
||||
if (_executing) return
|
||||
_executing = true
|
||||
const from = (e.from as HTMLElement).dataset.status
|
||||
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status
|
||||
const id = _dragId
|
||||
_dragId = null
|
||||
if (!to || !from || to === from) { _executing = false; return }
|
||||
if (!id) { _executing = false; return }
|
||||
kanbanBusy.value = true
|
||||
try {
|
||||
await taskApi.advance(id, to)
|
||||
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
|
||||
} catch (err) {
|
||||
showToast(err?.toString?.() ?? t('common.unknownError'), 'error')
|
||||
} finally {
|
||||
kanbanBusy.value = false
|
||||
_executing = false
|
||||
await opts.onRefresh()
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return { kanbanBusy, setupKanbanSortable: setup, destroyKanban: destroy }
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
done: 'tasks.status.done',
|
||||
blocked: 'tasks.status.blocked',
|
||||
cancelled: 'tasks.status.cancelled',
|
||||
deferred: 'tasks.status.deferred',
|
||||
}
|
||||
|
||||
// class 名对齐既有 css(Tasks/TaskDetail/ProjectDetail 均有 status-todo/progress/review/done/abandoned):
|
||||
@@ -103,6 +104,7 @@ export const TASK_STATUS_CLASS: Record<string, string> = {
|
||||
done: 'status-done',
|
||||
blocked: 'status-blocked',
|
||||
cancelled: 'status-abandoned',
|
||||
deferred: 'status-deferred',
|
||||
}
|
||||
|
||||
// D-260616-01 删旧 5 态前的老 DB 数据兼容映射(老 DB 可能存 completed/review_ready/merged/abandoned)
|
||||
@@ -128,16 +130,76 @@ export function taskStatusClass(status: string): string {
|
||||
}
|
||||
|
||||
// WK-8:任务状态机合法流转目标(源状态 → 可达目标态,对齐 TaskDetail.vue ADVANCE_MAP 的 target 集)。
|
||||
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done/cancelled 为终态,无推进目标);
|
||||
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done 为唯一终态,无推进目标;cancelled 可恢复);
|
||||
// 新建/手动改状态仍走后端 TaskStatus::is_valid 兜底校验。
|
||||
export const TASK_STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||
todo: ['in_progress', 'cancelled'],
|
||||
in_progress: ['in_review', 'blocked', 'cancelled'],
|
||||
in_review: ['testing', 'in_progress', 'blocked', 'cancelled'],
|
||||
testing: ['done', 'in_review', 'blocked', 'cancelled'],
|
||||
blocked: ['in_progress', 'cancelled'],
|
||||
todo: ['in_progress', 'cancelled', 'deferred'],
|
||||
in_progress: ['in_review', 'blocked', 'cancelled', 'deferred'],
|
||||
in_review: ['testing', 'in_progress', 'blocked', 'cancelled', 'deferred'],
|
||||
testing: ['done', 'in_review', 'blocked', 'cancelled', 'deferred'],
|
||||
blocked: ['in_progress', 'cancelled', 'deferred'],
|
||||
deferred: ['todo', 'cancelled'],
|
||||
cancelled: ['todo'],
|
||||
done: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
// 状态语义排序(推进主链在前 → 暂停态 → 历史旁支),供状态多选 chips / 快捷菜单 / 看板列统一使用。
|
||||
// 与后端 ALL_STATES / TASK_STATUS_TRANSITIONS 为同源集合,仅决定展示顺序。
|
||||
export const TASK_STATUS_ORDER = [
|
||||
'todo',
|
||||
'in_progress',
|
||||
'in_review',
|
||||
'testing',
|
||||
'done',
|
||||
'blocked',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
] as const
|
||||
|
||||
/** 推进按钮映射(与 TASK_STATUS_TRANSITIONS 同源,附带 label i18n key + variant 样式)。 */
|
||||
export interface AdvanceAction {
|
||||
target: string
|
||||
label: string
|
||||
variant: string
|
||||
}
|
||||
export const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
|
||||
todo: [
|
||||
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
|
||||
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
|
||||
{ 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: 'deferred', label: 'taskDetail.advance.defer', 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: 'deferred', label: 'taskDetail.advance.defer', 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: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
blocked: [
|
||||
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
|
||||
{ target: 'deferred', label: 'taskDetail.advance.defer', variant: 'btn-ghost' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
deferred: [
|
||||
{ target: 'todo', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
cancelled: [
|
||||
{ target: 'todo', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── 任务优先级 ──
|
||||
|
||||
@@ -41,6 +41,7 @@ export default {
|
||||
sendBack: 'Send Back',
|
||||
sendBackToReview: 'Send Back to Review',
|
||||
block: 'Block',
|
||||
defer: 'Defer',
|
||||
cancel: 'Cancel Task',
|
||||
},
|
||||
advancing: 'Advancing...',
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
done: 'Done',
|
||||
blocked: 'Blocked',
|
||||
cancelled: 'Cancelled',
|
||||
deferred: 'Deferred',
|
||||
},
|
||||
|
||||
// Groups
|
||||
@@ -53,6 +54,39 @@ export default {
|
||||
quickPriority: 'Priority',
|
||||
quickDelete: '🗑 Delete',
|
||||
|
||||
// Batch action bar (shared by Tasks / ProjectDetail / TaskDetail subtasks)
|
||||
batch: {
|
||||
selected: '{n} selected',
|
||||
selectAll: 'Select All',
|
||||
clear: 'Clear',
|
||||
advanceTo: 'Move to',
|
||||
noCommonTarget: 'No common target status for selected tasks',
|
||||
cancel: 'Cancel',
|
||||
defer: 'Defer',
|
||||
resume: 'Resume',
|
||||
priority: 'Priority',
|
||||
assignee: 'Assignee',
|
||||
assigneePlaceholder: 'Enter assignee…',
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Delete {n} tasks? {c} subtasks will be cascaded.',
|
||||
confirmDeletePlain: 'Delete {n} tasks?',
|
||||
deleteResult: 'Deleted {n} tasks ({c} subtasks cascaded)',
|
||||
advanceResult: 'Advanced {n} tasks',
|
||||
updateResult: 'Updated {n} tasks',
|
||||
skipped: '{n} tasks skipped (not allowed)',
|
||||
failed: '{n} failed',
|
||||
selectHint: 'Select tasks for batch actions',
|
||||
},
|
||||
|
||||
// Task kanban board
|
||||
kanban: {
|
||||
toggleList: 'List',
|
||||
toggleKanban: 'Board',
|
||||
emptyColumn: 'No tasks',
|
||||
dragHandle: 'Drag to move',
|
||||
parentNoDrag: 'Parent status is aggregated from subtasks and cannot be dragged',
|
||||
},
|
||||
|
||||
// New task modal
|
||||
modal: {
|
||||
title: 'New Task',
|
||||
@@ -85,6 +119,7 @@ export default {
|
||||
done: '✅ Done',
|
||||
blocked: '🚫 Blocked',
|
||||
cancelled: '🗑️ Cancelled',
|
||||
deferred: '⏰ Deferred',
|
||||
},
|
||||
// Error fallbacks (user-visible via state.error)
|
||||
err: {
|
||||
|
||||
@@ -41,6 +41,7 @@ export default {
|
||||
sendBack: '退回修改', // in_review → in_progress
|
||||
sendBackToReview: '退回重审', // testing → in_review
|
||||
block: '阻塞', // * → blocked
|
||||
defer: '暂缓', // * → deferred
|
||||
cancel: '取消任务', // * → cancelled
|
||||
},
|
||||
advancing: '推进中...',
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
done: '已完成',
|
||||
blocked: '已阻塞',
|
||||
cancelled: '已取消',
|
||||
deferred: '已暂缓',
|
||||
},
|
||||
|
||||
// 分组
|
||||
@@ -53,6 +54,39 @@ export default {
|
||||
quickPriority: '优先级',
|
||||
quickDelete: '🗑 删除',
|
||||
|
||||
// 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务)
|
||||
batch: {
|
||||
selected: '已选 {n} 项',
|
||||
selectAll: '全选',
|
||||
clear: '清空',
|
||||
advanceTo: '推进到',
|
||||
noCommonTarget: '所选任务无可统一推进的状态',
|
||||
cancel: '取消',
|
||||
defer: '暂缓',
|
||||
resume: '恢复',
|
||||
priority: '改优先级',
|
||||
assignee: '指派',
|
||||
assigneePlaceholder: '输入负责人…',
|
||||
delete: '删除',
|
||||
confirmDelete: '确定批量删除 {n} 个任务吗?将级联删除 {c} 个子任务。',
|
||||
confirmDeletePlain: '确定批量删除 {n} 个任务吗?',
|
||||
deleteResult: '已删除 {n} 个任务(含级联 {c} 个子任务)',
|
||||
advanceResult: '已推进 {n} 个任务',
|
||||
updateResult: '已更新 {n} 个任务',
|
||||
skipped: '{n} 个任务不允许该操作,已跳过',
|
||||
failed: '失败 {n}',
|
||||
selectHint: '勾选任务后批量操作',
|
||||
},
|
||||
|
||||
// 任务看板
|
||||
kanban: {
|
||||
toggleList: '列表',
|
||||
toggleKanban: '看板',
|
||||
emptyColumn: '暂无任务',
|
||||
dragHandle: '拖动以移动',
|
||||
parentNoDrag: '父任务状态由子任务聚合,不能直接拖动',
|
||||
},
|
||||
|
||||
// 新建任务模态
|
||||
modal: {
|
||||
title: '新建任务',
|
||||
@@ -85,6 +119,7 @@ export default {
|
||||
done: '✅ 已完成',
|
||||
blocked: '🚫 已阻塞',
|
||||
cancelled: '🗑️ 已取消',
|
||||
deferred: '⏰ 已暂缓',
|
||||
},
|
||||
// 错误回退(state.error 用户可见)
|
||||
err: {
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
|
||||
|
||||
/* — 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享 —
|
||||
原 IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致
|
||||
|
||||
+147
-12
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
/* 搜索框 */
|
||||
|
||||
Reference in New Issue
Block a user