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