1144 lines
48 KiB
Vue
1144 lines
48 KiB
Vue
<template>
|
||
<div class="task-detail">
|
||
<!-- 页面头部(吸顶):标题 + 状态 + 优先级 在左,推进按钮在右(滚动始终可见)。
|
||
Problem4 ① 推进按钮从面板内移到 header 右侧,与 refresh 同区。-->
|
||
<header class="page-header task-detail-header">
|
||
<div class="header-left">
|
||
<router-link to="/tasks" class="back-link">{{ $t('taskDetail.backToList') }}</router-link>
|
||
<h1>{{ task?.title ?? '...' }}</h1>
|
||
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||
<!-- F-04 review 轮次显示(>0 才显示,in_review→in_progress / testing→in_review 退回时后端 +1) -->
|
||
<span v-if="task && task.review_rounds > 0" class="review-rounds-badge">
|
||
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
|
||
</span>
|
||
<span v-if="task" class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="btn btn-ghost btn-sm" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
|
||
<!-- F-260616-06 ①-1 / B-41 工作流推进按钮(联动任务,按 target_status 后端自动选推进链模板)
|
||
与手动 advance 并存:仅当当前态的 primary 前向推进目标 ∈ {in_progress, testing, done}
|
||
(即 template_for 有对应模板)时显示。手动 advance 仍走 advance_task 直 IPC;工作流推进
|
||
走 run_workflow,经 AiNode/HumanNode 执行后再由后端回调推进任务。
|
||
独立 loading 标志 wfAdvancing 与 advancing 互不干扰。 -->
|
||
<button
|
||
v-if="wfAdvanceAction"
|
||
type="button"
|
||
class="btn btn-sm btn-primary"
|
||
:disabled="wfAdvancing || advancing"
|
||
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
|
||
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
|
||
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
|
||
<button
|
||
v-for="act in advanceActions"
|
||
:key="act.target"
|
||
type="button"
|
||
class="btn btn-sm"
|
||
:class="act.variant"
|
||
:disabled="advancing || wfAdvancing"
|
||
@click="handleAdvance(act.target)"
|
||
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 加载/错误态 -->
|
||
<div v-if="loading" class="empty-hint">{{ $t('taskDetail.loading') }}</div>
|
||
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
|
||
|
||
<!-- 主体:两栏布局(Problem4 ⑤)。左栏=描述+关联信息+时间戳,右栏=任务产出+工作流。
|
||
窄屏自动单列。-->
|
||
<div v-else-if="task" class="detail-grid">
|
||
<!-- ============ 左栏 ============ -->
|
||
<div class="left-column">
|
||
<!-- P2-9 子任务提示条(仅子任务显示,指明隶属父任务) -->
|
||
<div v-if="task.parent_id" class="subtask-hint">{{ $t('taskDetail.subtaskHint', { title: parentTaskTitle || task.parent_id }) }}</div>
|
||
|
||
<!-- 描述(可编辑:有描述渲染+折叠 / 无描述空态;编辑态 textarea,对齐 IdeaDetail 模式) -->
|
||
<section class="panel">
|
||
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
|
||
<template v-if="editingDesc">
|
||
<textarea v-model="editDesc" class="detail-desc-edit" rows="4" :placeholder="$t('taskDetail.descPlaceholder')"></textarea>
|
||
<div class="desc-edit-actions">
|
||
<button class="btn btn-primary btn-sm" :disabled="savingDesc" @click="saveEditDesc">{{ $t('taskDetail.saveDesc') }}</button>
|
||
<button class="btn btn-ghost btn-sm" :disabled="savingDesc" @click="cancelEditDesc">{{ $t('taskDetail.cancelEdit') }}</button>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div v-if="task.description" class="description-wrap" :style="descWrapStyle">
|
||
<span ref="descEl" class="value description ai-md" v-html="renderedDesc"></span>
|
||
<button
|
||
v-if="descCollapsible"
|
||
class="btn btn-ghost btn-sm desc-toggle"
|
||
type="button"
|
||
@click="descExpanded = !descExpanded"
|
||
>{{ descExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
|
||
<div v-if="descCollapsible && !descExpanded" class="desc-fade"></div>
|
||
</div>
|
||
<div v-else class="empty-hint desc-empty">{{ $t('taskDetail.descEmpty') }}</div>
|
||
<button class="btn btn-ghost btn-sm desc-edit-btn" :disabled="savingDesc" @click="startEditDesc">
|
||
{{ task.description ? $t('taskDetail.editDesc') : $t('taskDetail.addDesc') }}
|
||
</button>
|
||
</template>
|
||
</section>
|
||
|
||
<!-- 关联信息(Problem4 ③④ 去重 + 空值不显行)。
|
||
标题/状态/优先级已在 header 显示,这里不重复。-->
|
||
<section class="panel">
|
||
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
|
||
<div class="task-info">
|
||
<!-- F-260805 父子任务:父面包屑,仅子任务(parent_id 有值)显示,跳 /tasks/{parent_id} -->
|
||
<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">{{ 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">
|
||
{{ projectName }}
|
||
</router-link>
|
||
<span v-else>—</span>
|
||
</span>
|
||
</div>
|
||
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
|
||
<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">{{ 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>
|
||
<!-- 负责人(空值不显行,Problem4 ③) -->
|
||
<div v-if="task.assignee" class="info-item">
|
||
<span class="label">{{ $t('taskDetail.assignee') }}</span>
|
||
<span class="value">{{ task.assignee }}</span>
|
||
</div>
|
||
<!-- 基础分支(空值不显行) -->
|
||
<div v-if="task.base_branch" class="info-item">
|
||
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
|
||
<span class="value mono">{{ task.base_branch }}</span>
|
||
</div>
|
||
<!-- 工作流定义(空值不显行) -->
|
||
<div v-if="task.workflow_def_id" class="info-item">
|
||
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
|
||
<span class="value mono">{{ task.workflow_def_id }}</span>
|
||
</div>
|
||
<!-- 创建/更新时间:最底小字(Problem4 ⑥) -->
|
||
<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>
|
||
|
||
<!-- F-260805 父子任务:子任务面板。仅顶层任务(无 parent_id)可作为父任务(1 级嵌套约束),
|
||
顶层无子时显示空态 + 添加入口(空树创建)。 -->
|
||
<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>
|
||
<!-- 全选(勾选后触发底部批量操作栏) -->
|
||
<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">
|
||
<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}`)"
|
||
>
|
||
<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>
|
||
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleChildMenu(child.id)">⚙️</button>
|
||
<!-- 子任务行快捷菜单(复用列表页 quickStatuses/quickPriorities 模式,advance 后刷新子列表) -->
|
||
<div v-if="childMenuId === child.id" class="quick-menu" @click.stop>
|
||
<div class="quick-menu-section">
|
||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||
<button v-for="s in quickStatusesFor(child)" :key="s.key" class="quick-menu-item" @click="advanceChild(child, s.key)">
|
||
<span>{{ s.icon }}</span>{{ s.label }}
|
||
</button>
|
||
</div>
|
||
<div class="quick-menu-divider"></div>
|
||
<div class="quick-menu-section">
|
||
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="setChildPriority(child, p.value)">
|
||
<span :class="p.cls">●</span>{{ p.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 空态:父任务无子任务 -->
|
||
<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>
|
||
|
||
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
|
||
<div class="right-column">
|
||
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
|
||
<section class="panel">
|
||
<div class="panel-header"><h2>{{ $t('taskDetail.output') }}</h2></div>
|
||
<!-- 无产出时占位(任务未跑过 AiNode 产出),右栏保留产出栏框架便于后续产出出现即填入 -->
|
||
<div v-if="!task.output_json" class="empty-hint output-empty">—</div>
|
||
<TaskOutputCard v-else :output-json="task.output_json" />
|
||
</section>
|
||
|
||
<!-- B-41 工作流推进轻量进度(从面板内 advance-row 提取,推进按钮已移 header) -->
|
||
<section v-if="wfAdvancing || wfProgressHint" class="panel">
|
||
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
|
||
<div class="wf-progress">
|
||
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
|
||
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
|
||
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
|
||
</span>
|
||
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
|
||
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
||
</div>
|
||
<!-- 工作流 DAG 结构 -->
|
||
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- F-260805:子任务新建小弹窗(标题 + 优先级 + 可选描述),project_id/parent_id 继承当前任务 -->
|
||
<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>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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'
|
||
import { taskApi, projectApi, ideaApi } from '@/api'
|
||
import { workflowApi } from '@/api'
|
||
import { useProjectStore } from '@/stores/project'
|
||
import { formatDate } from '@/utils/time'
|
||
import { useRendered } from '@/composables/useMarkdown'
|
||
import {
|
||
taskStatusLabel,
|
||
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()
|
||
const router = useRouter()
|
||
// B-41: 共享 workflow store(liveEvents 全局收集 + workflowProgress 派生进度)
|
||
const store = useProjectStore()
|
||
|
||
const loading = ref(true)
|
||
const errorMsg = ref('')
|
||
const task = ref<TaskRecord | null>(null)
|
||
const projects = ref<ProjectRecord[]>([])
|
||
// F-260619-01:灵感列表用于解析 task.idea_id → 灵感 title(独立入口,同 projects 模式)
|
||
const ideas = ref<IdeaRecord[]>([])
|
||
const advancing = ref(false)
|
||
|
||
// ============================================================
|
||
// F-260805 父子任务:父面包屑 + 子任务面板
|
||
// ------------------------------------------------------------
|
||
// 本视图绕 store 直调 taskApi,父子数据本地维护:
|
||
// - 子任务(parent_id 有值):额外取父任务标题供面包屑
|
||
// - 顶层任务(无 parent_id):拉子任务列表 + 父进度(前端计算),advance/改优先级后手动刷新
|
||
const children = ref<TaskRecord[]>([])
|
||
const parentTaskTitle = ref<string | null>(null)
|
||
// 子任务行快捷菜单(当前打开的子任务 id)
|
||
const childMenuId = ref<string | null>(null)
|
||
function toggleChildMenu(id: string) {
|
||
childMenuId.value = childMenuId.value === id ? null : id
|
||
}
|
||
function closeChildMenu() { childMenuId.value = null }
|
||
|
||
// 复用列表页 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))
|
||
return quickStatuses.value.filter((s) => valid.has(s.key))
|
||
}
|
||
const quickPriorities = computed(() => [
|
||
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
|
||
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
|
||
{ value: 2, label: t('tasks.modal.priorityMedium'), cls: 'priority-medium' },
|
||
{ value: 3, label: t('tasks.modal.priorityLow'), cls: 'priority-low' },
|
||
])
|
||
|
||
/** 子任务父进度 = children 中 done+cancelled / total(仅顶层任务有 children) */
|
||
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 }
|
||
})
|
||
|
||
/** 拉取当前任务的直接子任务(仅顶层任务可作父,1 级嵌套) */
|
||
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 (e) {
|
||
console.error('[TaskDetail] 拉取子任务失败:', e)
|
||
children.value = []
|
||
}
|
||
}
|
||
|
||
// ── 子任务批量操作;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
|
||
if (!cur || !cur.parent_id) { parentTaskTitle.value = null; return }
|
||
try {
|
||
const parent = await taskApi.get(cur.parent_id)
|
||
parentTaskTitle.value = parent.title
|
||
} catch (e) {
|
||
console.error('[TaskDetail] 拉取父任务标题失败:', e)
|
||
parentTaskTitle.value = null
|
||
}
|
||
}
|
||
|
||
/** 子任务快捷推进(advance 后刷新子列表,父进度随之更新) */
|
||
async function advanceChild(child: TaskRecord, target: string) {
|
||
childMenuId.value = null
|
||
try {
|
||
await taskApi.advance(child.id, target)
|
||
await loadChildren()
|
||
} catch (e: any) {
|
||
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
}
|
||
}
|
||
|
||
/** 子任务快捷改优先级(update 后刷新子列表) */
|
||
async function setChildPriority(child: TaskRecord, priority: number) {
|
||
childMenuId.value = null
|
||
try {
|
||
await taskApi.update(child.id, 'priority', String(priority))
|
||
await loadChildren()
|
||
} catch (e: any) {
|
||
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
}
|
||
}
|
||
|
||
// F-260805:子任务新建小弹窗
|
||
const showSubtaskModal = ref(false)
|
||
const subtaskTitle = ref('')
|
||
const subtaskDesc = ref('')
|
||
const subtaskPriority = ref(2)
|
||
const subtaskSubmitting = ref(false)
|
||
function openSubtaskModal() {
|
||
subtaskTitle.value = ''
|
||
subtaskDesc.value = ''
|
||
subtaskPriority.value = 2
|
||
showSubtaskModal.value = true
|
||
}
|
||
async function confirmSubtask() {
|
||
const cur = task.value
|
||
if (!cur || !subtaskTitle.value.trim() || subtaskSubmitting.value) return
|
||
const title = subtaskTitle.value.trim()
|
||
if (title.length > 128) { errorMsg.value = t('taskDetail.subtaskTitleTooLong'); return }
|
||
if (children.value.some((c) => c.title === title)) { errorMsg.value = t('taskDetail.subtaskDuplicate'); return }
|
||
subtaskSubmitting.value = true
|
||
try {
|
||
const r = await taskApi.create({
|
||
project_id: cur.project_id,
|
||
title,
|
||
description: subtaskDesc.value.trim() || undefined,
|
||
priority: subtaskPriority.value,
|
||
// project_id 继承当前任务、parent_id = 当前任务 id
|
||
parent_id: cur.id,
|
||
})
|
||
if (!r) return
|
||
showSubtaskModal.value = false
|
||
errorMsg.value = ''
|
||
await loadChildren()
|
||
} catch (e: any) {
|
||
errorMsg.value = t('taskDetail.addSubtaskFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
} finally {
|
||
subtaskSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
|
||
// ------------------------------------------------------------
|
||
// wfAdvancing:推进中态;wfExecId:当前监听的工作流执行 ID(过滤 workflow-event);
|
||
// wfTotalNodes/wfDoneCount/wfRunningNode:轻量进度(由共享 store 按 execId 派生);
|
||
// wfResult: 'completed' | 'failed' | null —— 终态提示,完成/失败后自动清空(由新一次推进重置)。
|
||
const wfAdvancing = ref(false)
|
||
const wfExecId = ref<string | null>(null)
|
||
const wfResult = ref<'completed' | 'failed' | null>(null)
|
||
// SW-260618-21: 终态提示 timer 引用,卸载时清理防写已销毁 ref(对齐 Projects.vue _toastTimer 模式)
|
||
let _wfResultTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
/** 重置工作流推进相关 UI 态(切任务/refresh/手动推进成功后调用。
|
||
注意:df-data-changed 触发的 load() 不调此函数,避免工作流推进中 AI 工具改任务触发刷新时进度面板被误清) */
|
||
function resetWorkflowUi() {
|
||
wfExecId.value = null
|
||
wfDagJson.value = ''
|
||
wfResult.value = null
|
||
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
||
}
|
||
|
||
// B-41: 共享工作流进度推导。workflow store 已全局收集 liveEvents,此处按 execId
|
||
// 派生节点状态/进度,与 ProjectDetail 同源(store.workflowProgress),替代原本地 onEvent 逐事件累加。
|
||
const wfProgress = computed(() => store.workflowProgress(wfExecId.value))
|
||
const wfNodeStatuses = computed(() => wfProgress.value.nodeStatuses)
|
||
const wfDoneCount = computed(() => wfProgress.value.doneCount)
|
||
const wfTotalNodes = computed(() => wfProgress.value.totalNodes)
|
||
const wfRunningNode = computed(() => wfProgress.value.runningNode)
|
||
const wfDoneTotal = computed(() => wfTotalNodes.value)
|
||
|
||
const wfProgressHint = computed(() => wfResult.value !== null)
|
||
const wfCompletedHint = computed(() => wfResult.value === 'completed')
|
||
const wfFailedHint = computed(() => wfResult.value === 'failed')
|
||
|
||
// 终态 → 瞬态提示:派生 result 置位时收 wfAdvancing + 3s 后清 wfResult(仅 UI hint,数据不改)
|
||
watch(() => wfProgress.value.result, (r) => {
|
||
if (!r) return
|
||
wfResult.value = r
|
||
wfAdvancing.value = false
|
||
if (_wfResultTimer) clearTimeout(_wfResultTimer)
|
||
_wfResultTimer = setTimeout(() => {
|
||
if (wfResult.value === r) wfResult.value = null
|
||
_wfResultTimer = null
|
||
}, 3000)
|
||
})
|
||
|
||
// 工作流 DAG 结构展示(执行记录读回,节点状态由共享派生)
|
||
const wfDagJson = ref('')
|
||
async function refreshWorkflowDag(execId: string) {
|
||
try {
|
||
const record = await workflowApi.getExecution(execId)
|
||
if (record?.dag_json) wfDagJson.value = record.dag_json
|
||
} catch { /* 静默 */ }
|
||
}
|
||
|
||
const taskId = computed(() => route.params.id as string)
|
||
|
||
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
|
||
// computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
|
||
const { rendered: renderedDesc, ensureLoaded } = useRendered(
|
||
() => task.value?.description ?? '',
|
||
)
|
||
|
||
// ============================================================
|
||
// Problem4 ②:描述可折叠 — 超 400px 显展开按钮(测真实 DOM 高度)
|
||
// ------------------------------------------------------------
|
||
// 不用字符数近似(渲染后 Markdown 列表/标题行高不一,字符数与 px 无线性关系),
|
||
// 改用 descEl 实测 scrollHeight:首次渲染 + task 切换后 nextTick 测量。
|
||
// descCollapsible=true 时才显按钮;默认折叠(!descExpanded)即首次进入截 400px。
|
||
// 折叠态 max-height 由 descWrapStyle 内联注入(展开态不设上限,自适应)。
|
||
// .description-wrap 的 overflow:hidden + transition 让折叠/展开有过渡。
|
||
// 蒙层 .desc-fade 暗示折叠态下方还有内容。
|
||
const DESC_COLLAPSE_PX = 400
|
||
const descEl = ref<HTMLElement | null>(null)
|
||
const descExpanded = ref(false)
|
||
const descOverflow = ref(false) // 真实高度是否超阈值(控制按钮显隐)
|
||
|
||
const descCollapsible = computed(() => descOverflow.value)
|
||
|
||
// 折叠态 max-height 内联(过渡友好):展开态不设上限(自适应)
|
||
const descWrapStyle = computed(() =>
|
||
descExpanded.value ? {} : { maxHeight: DESC_COLLAPSE_PX + 'px' },
|
||
)
|
||
|
||
async function measureDescHeight() {
|
||
// descEl 是 <span>(描述内容),自身无 max-height 限制 → scrollHeight 即其真实渲染高度,
|
||
// 不受父 .description-wrap 的 max-height 裁剪影响(span 在父内若被裁,其 scrollHeight
|
||
// 仍反映完整内容高)。直接读无需临时改父样式。
|
||
await nextTick()
|
||
const el = descEl.value
|
||
if (!el) { descOverflow.value = false; return }
|
||
descOverflow.value = el.scrollHeight > DESC_COLLAPSE_PX
|
||
}
|
||
|
||
// P2-10: 描述编辑(对齐 IdeaDetail 模式;taskApi.update(id, 'description', value))
|
||
const editingDesc = ref(false)
|
||
const editDesc = ref('')
|
||
const savingDesc = ref(false)
|
||
function startEditDesc() {
|
||
editDesc.value = task.value?.description ?? ''
|
||
editingDesc.value = true
|
||
}
|
||
function cancelEditDesc() { editingDesc.value = false }
|
||
async function saveEditDesc() {
|
||
if (!task.value || savingDesc.value) return
|
||
savingDesc.value = true
|
||
try {
|
||
await taskApi.update(task.value.id, 'description', editDesc.value)
|
||
task.value = { ...task.value, description: editDesc.value }
|
||
editingDesc.value = false
|
||
errorMsg.value = ''
|
||
} catch (e: any) {
|
||
errorMsg.value = t('taskDetail.descSaveFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
} finally {
|
||
savingDesc.value = false
|
||
}
|
||
}
|
||
|
||
// F-AiNodeSelfReview: output_json 解析 + AI 产出/自审渲染已抽至 TaskOutputCard 子组件
|
||
// (components/task/TaskOutputCard.vue),父级只传 output-json prop,零行为变更。
|
||
|
||
const projectName = computed(() => {
|
||
const p = projects.value.find(p => p.id === task.value?.project_id)
|
||
return p?.name ?? task.value?.project_id ?? '—'
|
||
})
|
||
|
||
// F-260619-01:解析 task.idea_id → 灵感 title(找不到时回退 id,保证非空可点击)
|
||
const ideaTitle = computed(() => {
|
||
const ideaId = task.value?.idea_id
|
||
if (!ideaId) return '—'
|
||
const idea = ideas.value.find(i => i.id === ideaId)
|
||
return idea?.title ?? ideaId
|
||
})
|
||
|
||
// ============================================================
|
||
// F-05 推进按钮 — 状态机合法下一态映射
|
||
// ------------------------------------------------------------
|
||
// 真相源:后端 df-nodes task_state_machine.rs can_transition(详见该文件注释矩阵)。
|
||
// 前端只做「按当前 status 显示哪些合法目标」的渲染决策,不做合法性判定;
|
||
// 点击调 advance_task,后端二次校验(can_transition)+ 原子写 + review_rounds 累加。
|
||
//
|
||
// 矩阵(行=from):
|
||
// 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
|
||
// done/cancelled → 终态,无按钮
|
||
const advanceActions = computed<AdvanceAction[]>(() => {
|
||
const s = task.value?.status
|
||
if (!s) return []
|
||
return ADVANCE_MAP[s] ?? []
|
||
})
|
||
|
||
// F-260616-06 ①-1: 工作流推进按钮的目标态。
|
||
// 仅当当前态的 primary(前向推进)目标 ∈ template_for 支持的三态 {in_progress, testing, done} 时显示。
|
||
// 非前向(退回/阻塞/取消)不走工作流(无对应模板,且语义上工作流只做前向推进)。
|
||
// ADVANCE_MAP 各行的首个 primary 项:todo→in_progress, in_review→testing, testing→done, blocked→in_progress
|
||
// in_progress 的 primary 是 in_review(无模板,不显示工作流推进按钮)。
|
||
const WF_SUPPORTED_TARGETS = new Set(['in_progress', 'testing', 'done'])
|
||
// CR-08-O1: blocked 是阻塞态(任务被阻塞),其 primary=in_progress 虽 ∈ WF_SUPPORTED_TARGETS,
|
||
// 但 template_for('in_progress') 是「todo→in_progress 从头执行」拓扑(AiNode 等重新跑一遍),
|
||
// blocked 恢复语义不该重跑 AiNode(任务已有上下文/进度,只需恢复非从头执行)。
|
||
// 最小修法:blocked 不显示工作流推进按钮,用户手动 advance(blocked→in_progress 走后端状态机)或先解除阻塞。
|
||
// (引入 blocked 恢复模板需后端 template_for 扩展,复杂度高,非阻断 med 项留待 UX 决策。)
|
||
const WF_EXCLUDED_FROM = new Set(['blocked'])
|
||
const wfAdvanceAction = computed<AdvanceAction | null>(() => {
|
||
const s = task.value?.status
|
||
if (!s) return null
|
||
if (WF_EXCLUDED_FROM.has(s)) return null
|
||
const actions = ADVANCE_MAP[s] ?? []
|
||
const primary = actions.find(a => a.variant === 'btn-primary')
|
||
if (!primary) return null
|
||
return WF_SUPPORTED_TARGETS.has(primary.target) ? primary : null
|
||
})
|
||
|
||
async function handleAdvance(target: string) {
|
||
if (!task.value || advancing.value) return
|
||
advancing.value = true
|
||
try {
|
||
// advance_task 返回更新后的 TaskRecord(含新 status / review_rounds)
|
||
const updated = await taskApi.advance(task.value.id, target)
|
||
task.value = updated
|
||
// P2-8: 手动推进成功后清工作流 UI 态(防上一轮工作流残留进度/终态提示污染新任务态)
|
||
resetWorkflowUi()
|
||
errorMsg.value = ''
|
||
} catch (e: any) {
|
||
errorMsg.value = t('taskDetail.advanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
} finally {
|
||
advancing.value = false
|
||
}
|
||
}
|
||
|
||
// F-260616-06 ①-1: 工作流推进 — 传空 dag + target_status,后端自动选推进链模板。
|
||
// B-41: 监听 workflow-event 显示轻量进度;完成/失败后任务态由后端回调推进(②-3/②-4),
|
||
// df-data-changed 自动刷新 task(AR-11 已通),此处不直接改 task.value。
|
||
// name 用推进链标识(模板内未硬编码 task_id,运行时由后端 config 注入,前端只传 topology hint)。
|
||
async function handleWorkflowAdvance(target: string) {
|
||
if (!task.value || wfAdvancing.value || advancing.value) return
|
||
wfAdvancing.value = true
|
||
wfResult.value = null
|
||
try {
|
||
const execId = await workflowApi.run(
|
||
`task-advance:${target}`,
|
||
{}, // 空 dag → 后端 template_for(target) 选模板
|
||
{}, // 全局 config 留空(节点级 config 由模板 gate 注入 + 运行时 task_id 注入)
|
||
task.value.id,
|
||
target,
|
||
)
|
||
wfExecId.value = execId
|
||
errorMsg.value = ''
|
||
await refreshWorkflowDag(execId)
|
||
} catch (e: any) {
|
||
wfAdvancing.value = false
|
||
wfExecId.value = null
|
||
errorMsg.value = t('taskDetail.workflowAdvanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
}
|
||
}
|
||
|
||
// B-41: 进度状态已由共享 store 按 execId 派生(store.workflowProgress),不再本地逐事件累加。
|
||
// 原有 handleWorkflowEvent 已移除 —— 事件仍由 workflow store 全局收集,TaskDetail 只读派生结果。
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
errorMsg.value = ''
|
||
try {
|
||
const [t, ps, ideasList] = await Promise.all([
|
||
taskApi.get(taskId.value),
|
||
// 项目列表用于解析 project_id → 项目名(独立入口,不依赖全局 store)
|
||
projectApi.list(),
|
||
// F-260619-01:灵感列表用于解析 idea_id → 灵感 title
|
||
ideaApi.list(),
|
||
])
|
||
task.value = t
|
||
projects.value = ps
|
||
ideas.value = ideasList
|
||
// F-260805:父子数据(子任务取父标题 / 顶层任务取子任务列表),与主数据并行
|
||
await Promise.all([loadParentTitle(), loadChildren()])
|
||
} catch (e: any) {
|
||
task.value = null
|
||
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function refresh() {
|
||
resetWorkflowUi()
|
||
load()
|
||
}
|
||
|
||
// 路由参数变化(id 变化)时重新加载
|
||
watch(taskId, () => { resetWorkflowUi(); load() })
|
||
|
||
// Problem4 ②:切换 task / 描述渲染完成 → 重置折叠态 + 重测高度。
|
||
// renderedDesc 异步(Markdown 经 marked 渲染),内容变化后 nextTick 测真实 px。
|
||
watch(() => task.value?.id, () => { descExpanded.value = false })
|
||
watch(renderedDesc, () => { measureDescHeight() })
|
||
|
||
// B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
||
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
|
||
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
|
||
// 故此本地监听 entity=task/project 时重载当前 task。
|
||
let _unlistenDataChanged: (() => void) | null = null
|
||
|
||
onMounted(async () => {
|
||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||
load()
|
||
// F-260805:点击子任务快捷菜单外关闭菜单
|
||
document.addEventListener('click', closeChildMenu)
|
||
// B-260616-18: 后端 AI 工具(create/update/delete 等)emit df-data-changed → 本视图重载当前 task
|
||
// entity=task:任务本体字段(标题/状态/描述/分支…)被改时刷新;entity=project:项目名变更影响 projectName 解析时刷新
|
||
try {
|
||
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
|
||
const { entity } = event.payload
|
||
if (entity === 'task' || entity === 'project' || entity === 'idea') {
|
||
load()
|
||
}
|
||
})
|
||
} catch (e) {
|
||
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
|
||
}
|
||
// B-41: 工作流进度由 workflow store 全局 liveEvents 推导(store.workflowProgress),
|
||
// 此处只需确保 store 事件监听已启动(幂等,共享单例;按 execution_id 过滤由派生层完成)。
|
||
try {
|
||
await store.startEventListener()
|
||
} catch (e) {
|
||
console.error('[TaskDetail] 启动 workflow 事件监听失败:', e)
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||
// F-260805:移除子任务快捷菜单关闭监听
|
||
document.removeEventListener('click', closeChildMenu)
|
||
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
|
||
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.task-detail { padding: 16px 20px 20px; }
|
||
|
||
/* page-header / btn / back-link 等已提取到 global.css 全局 */
|
||
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||
.header-left h1 {
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Problem4 ① 吸顶 header(推进按钮始终可见) */
|
||
.task-detail-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background: var(--df-bg);
|
||
/* 留底部细分割线,与全局 page-header margin-bottom 协同(0.5px 边框规范) */
|
||
padding-bottom: 8px;
|
||
border-bottom: 0.5px solid var(--df-border);
|
||
/* 顶负 padding 抵消 .task-detail 的 16px 顶 padding,吸顶贴顶 */
|
||
margin: -16px -20px 0;
|
||
padding-left: 20px;
|
||
padding-right: 20px;
|
||
padding-top: 16px;
|
||
align-items: center;
|
||
}
|
||
/* header-actions 内多个推进按钮 + refresh 排列(对齐全局 .header-actions gap:10px) */
|
||
.task-detail-header .header-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||
|
||
/* advance 操作区已移入 header-actions,旧 .advance-row 样式删除 */
|
||
|
||
/* B-41 工作流推进轻量进度提示 */
|
||
.wf-progress {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: var(--df-text-secondary);
|
||
}
|
||
.wf-progress-count { color: var(--df-text-dim); }
|
||
.wf-progress-hint { color: var(--df-success); }
|
||
.wf-progress-hint-fail { color: var(--df-danger); }
|
||
|
||
/* F-04 review 轮次徽章 */
|
||
.review-rounds-badge {
|
||
font-size: 11px;
|
||
color: var(--df-warning);
|
||
background: rgba(255,217,61,0.12);
|
||
padding: 3px 8px;
|
||
border-radius: var(--df-radius-xs);
|
||
margin-left: 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 状态/优先级标签 — 尺寸/圆角由全局 components.css .status-tag 统一,scoped 不重复定义 */
|
||
/* 状态色类已全局化至 components.css(scoped 删除,消除漂移) */
|
||
|
||
.priority-badge {
|
||
font-size: 10px; font-weight: 500;
|
||
padding: 1px 6px;
|
||
border-radius: var(--df-radius-xs);
|
||
}
|
||
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||
.priority-high { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||
.priority-medium { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||
|
||
/* ===== 面板 ===== */
|
||
/* Problem4 ⑤ 宽屏两栏:左栏(描述+关联信息+时间戳) / 右栏(任务产出+工作流进度)。
|
||
窄屏(<960px)单列。对齐 ProjectDetail 两栏模式但阈值更宽(产出栏更窄)。
|
||
不设 margin-top —— 全局 .page-header 已有 margin-bottom: var(--df-gap-page) 提供分隔。*/
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 380px;
|
||
gap: var(--df-gap-page);
|
||
align-items: start; /* 两栏顶对齐,长栏不撑高短栏 */
|
||
}
|
||
.left-column, .right-column {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--df-gap-grid);
|
||
min-width: 0; /* 网格项防溢出(长 url/代码块) */
|
||
}
|
||
|
||
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619)。 */
|
||
|
||
/* ===== 信息列表 ===== */
|
||
.task-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--df-gap-grid);
|
||
}
|
||
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状)
|
||
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
|
||
此处仅保留本组件特有覆盖。 */
|
||
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li)。
|
||
Problem4 重设计:描述区从 .info-item 内移到独立 .description-wrap(可折叠容器),
|
||
故选择器改 .description-wrap。display:block 让 <span> 有块盒模型,scrollHeight 可读。*/
|
||
.description-wrap .value.description { line-height: 1.5; display: block; }
|
||
|
||
/* Problem4 ② 描述可折叠:折叠态 max-height(内联注入 400px) + overflow hidden + 过渡。
|
||
展开态 max-height none(内联不设),自然撑开。蒙层 .desc-fade 暗示下方有内容。*/
|
||
.description-wrap {
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: max-height 0.25s var(--df-ease);
|
||
}
|
||
.desc-toggle {
|
||
margin-top: 8px;
|
||
}
|
||
.desc-fade {
|
||
/* 折叠态底部渐变蒙层,提示"下方还有内容"(展开态不渲染此元素) */
|
||
position: absolute;
|
||
left: 0; right: 0; bottom: 0;
|
||
height: 32px;
|
||
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Problem4 ⑥ 创建/更新时间最底小字(独立行,非 label/value 对) */
|
||
.info-item.timestamps {
|
||
flex-direction: row;
|
||
gap: 16px;
|
||
padding-top: var(--df-gap-grid);
|
||
border-top: 0.5px solid var(--df-border);
|
||
}
|
||
.timestamp {
|
||
font-size: 11px;
|
||
color: var(--df-text-dim);
|
||
}
|
||
|
||
/* 右栏空产出占位提示 */
|
||
.output-empty { padding: 24px 12px; }
|
||
|
||
/* F-AiNodeSelfReview output_json 区块样式已随子组件移至 TaskOutputCard.vue */
|
||
|
||
/* ===== 任务描述 Markdown 渲染(B-24,基础样式收敛至全局 ai-md.css) ===== */
|
||
|
||
.project-link {
|
||
color: var(--df-accent);
|
||
text-decoration: none;
|
||
font-weight: 500;
|
||
}
|
||
.project-link:hover { text-decoration: underline; }
|
||
|
||
.branch-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
font-size: 12px;
|
||
color: var(--df-text-secondary);
|
||
background: var(--df-accent-bg);
|
||
padding: 2px 8px;
|
||
border-radius: var(--df-radius-xs);
|
||
font-family: monospace;
|
||
}
|
||
.branch-icon { color: var(--df-accent); }
|
||
|
||
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||
|
||
.empty-hint {
|
||
text-align: center; padding: 24px 12px;
|
||
font-size: 13px; color: var(--df-text-dim);
|
||
}
|
||
.error-hint { color: var(--df-danger); }
|
||
|
||
/* ===== F-260805 父子任务:子任务面板 ===== */
|
||
.subtask-count {
|
||
font-size: 11px;
|
||
color: var(--df-text-dim);
|
||
margin-left: auto;
|
||
white-space: nowrap;
|
||
}
|
||
.subtask-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 6px 0 10px;
|
||
}
|
||
.subtask-progress-text {
|
||
font-size: 11px;
|
||
color: var(--df-text-dim);
|
||
white-space: nowrap;
|
||
}
|
||
/* 迷你进度条(与 Tasks.vue 树形列表同款,渐变填充) */
|
||
.mini-progress {
|
||
flex: 1;
|
||
height: 4px;
|
||
background: var(--df-border);
|
||
border-radius: var(--df-radius-xs);
|
||
overflow: hidden;
|
||
}
|
||
.mini-progress-fill {
|
||
height: 100%;
|
||
border-radius: var(--df-radius-xs);
|
||
background: linear-gradient(90deg, var(--df-accent), var(--df-success));
|
||
transition: width 0.3s;
|
||
}
|
||
.subtask-list {
|
||
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;
|
||
gap: 8px;
|
||
padding: 6px 8px;
|
||
border-radius: var(--df-radius-sm);
|
||
cursor: pointer;
|
||
position: relative; /* 快捷菜单绝对定位锚点 */
|
||
transition: background 0.1s;
|
||
}
|
||
.subtask-item:hover { background: var(--df-accent-bg); }
|
||
.subtask-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
color: var(--df-text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.subtask-quick-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
padding: 2px 4px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.subtask-item:hover .subtask-quick-btn { opacity: 0.6; }
|
||
.subtask-quick-btn:hover { opacity: 1; }
|
||
/* 子任务快捷菜单(复用列表页 quick-menu 同款视觉) */
|
||
.quick-menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
right: 0;
|
||
min-width: 160px;
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-sm);
|
||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||
z-index: 20;
|
||
padding: 6px 0;
|
||
}
|
||
.quick-menu-section { padding: 4px 0; }
|
||
.quick-menu-label {
|
||
font-size: 10px;
|
||
color: var(--df-text-dim);
|
||
padding: 2px 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.quick-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 5px 12px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--df-text);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
.quick-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||
.quick-menu-divider {
|
||
height: 0.5px;
|
||
background: var(--df-border);
|
||
margin: 4px 0;
|
||
}
|
||
.subtask-empty { padding: 16px 8px; }
|
||
.subtask-add { margin-top: 8px; }
|
||
|
||
/* P2-9 子任务提示条 */
|
||
.subtask-hint {
|
||
font-size: 12px;
|
||
color: var(--df-text-secondary);
|
||
background: var(--df-accent-bg);
|
||
padding: 6px 12px;
|
||
border-radius: var(--df-radius-sm);
|
||
margin-bottom: var(--df-gap-grid);
|
||
}
|
||
|
||
/* P2-10 描述编辑(对齐 IdeaDetail .detail-desc-edit 模式) */
|
||
.detail-desc-edit {
|
||
width: 100%;
|
||
min-height: 80px;
|
||
background: var(--df-bg);
|
||
color: var(--df-text);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-sm);
|
||
padding: 8px 10px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
resize: vertical;
|
||
}
|
||
.desc-edit-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||
.desc-empty { padding: 16px 8px; }
|
||
.desc-edit-btn { margin-top: 8px; }
|
||
|
||
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
|
||
@media (max-width: 960px) {
|
||
.detail-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* 窄屏 header-actions 内推进按钮可能换行,允许并压缩间距 */
|
||
@media (max-width: 720px) {
|
||
.task-detail-header { flex-wrap: wrap; }
|
||
.task-detail-header .header-left { flex: 1 1 100%; margin-bottom: 8px; }
|
||
}
|
||
</style>
|