Files
DevFlow/src/views/TaskDetail.vue
T

1144 lines
48 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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_reviewin_progress / testingin_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>