新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐

This commit is contained in:
2026-06-16 02:33:15 +08:00
parent f30df333b3
commit d2cb38cdac
21 changed files with 1628 additions and 167 deletions

View File

@@ -21,4 +21,13 @@ export const taskApi = {
delete(id: string): Promise<boolean> {
return invoke('delete_task', { id })
},
/**
* F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。
* 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时),
* 前端只传 target_status,不自行判状态机(单一真相源在后端)。
*/
advance(id: string, targetStatus: string): Promise<TaskRecord> {
return invoke('advance_task', { id, targetStatus })
},
}

View File

@@ -91,6 +91,8 @@ export interface TaskRecord {
assignee: string | null
workflow_def_id: string | null
base_branch: string | null
/** review 退回累计轮数(in_review→in_progress / testing→in_review 各 +1) */
review_rounds: number
created_at: string
updated_at: string
}

View File

@@ -6,22 +6,19 @@
// ── 项目状态 ──
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
// F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方),
// 保留与后端真实生命周期一致的最小集(planning 常态 + 软删 deleted_at 由 list_deleted 单独处理)。
export const PROJECT_STATUS_LABELS: Record<string, string> = {
planning: 'projects.status.planning',
in_progress: 'projects.status.in_progress',
paused: 'projects.status.paused',
completed: 'projects.status.completed',
cancelled: 'projects.status.cancelled',
active: 'projects.status.active',
completed: 'projects.status.completed',
}
/** 项目状态 → 卡片 badge 样式 class */
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
planning: 'stage-design',
in_progress: 'stage-coding',
paused: 'stage-testing',
active: 'stage-coding',
completed: 'stage-release',
cancelled: 'stage-design',
}
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
@@ -35,11 +32,9 @@ export interface ProjectStageInfo {
}
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
planning: { stepIndex: 0, progress: 20, stage: 'coding' },
in_progress: { stepIndex: 2, progress: 55, stage: 'coding' },
paused: { stepIndex: 2, progress: 40, stage: 'testing' },
planning: { stepIndex: 0, progress: 20, stage: 'planning' },
active: { stepIndex: 2, progress: 55, stage: 'coding' },
completed: { stepIndex: 4, progress: 100, stage: 'release' },
cancelled: { stepIndex: 0, progress: 0, stage: 'testing' },
}
export function projectStatusLabel(status: string): string {
@@ -51,26 +46,41 @@ export function projectBadgeClass(status: string): string {
}
export function projectStageInfo(status: string): ProjectStageInfo {
return PROJECT_STAGE_INFO[status] ?? PROJECT_STAGE_INFO.planning
// F-09:兜底用字面量而非 PROJECT_STAGE_INFO.planning,避免已删键的隐式依赖
return PROJECT_STAGE_INFO[status] ?? { stepIndex: 0, progress: 0, stage: 'planning' }
}
// ── 任务状态 ──
// D-260616-01:前端对齐后端 7 态纯状态机(crates/df-core/src/types.rs TaskStatus)
// 删旧 5 态 Git 工作流导向(review_ready/merged/abandoned)。
// 后端 task.rs update_task 写 status 经 TaskStatus::is_valid 拦截,旧 5 态无法落库;
// migrations.rs V1 无 seed,create_task 默认 todo → 新 DB 无旧 5 态数据,安全对齐。
//
// 值为 i18n key,实际文案走 $t('tasks.status.<key>')(见 src/i18n/{zh-CN,en}/tasks.ts)
export const TASK_STATUS_LABELS: Record<string, string> = {
todo: 'tasks.status.todo',
in_progress: 'tasks.status.in_progress',
review_ready: 'tasks.status.review_ready',
merged: 'tasks.status.merged',
abandoned: 'tasks.status.abandoned',
in_review: 'tasks.status.in_review',
testing: 'tasks.status.testing',
done: 'tasks.status.done',
blocked: 'tasks.status.blocked',
cancelled: 'tasks.status.cancelled',
}
// class 名对齐既有 css(Tasks/TaskDetail/ProjectDetail 均有 status-todo/progress/review/done/abandoned):
// - todo/in_progress/in_review/done 复用既有 class(保持跨视图样式一致,不破坏指派外文件)
// - cancelled 复用 status-abandoned(同为 danger 红色,语义一致)
// - testing/blocked 新增(Tasks.vue 自身样式补充;TaskDetail/ProjectDetail 遇此值兜底 status-todo 灰色,
// 非破坏——仅样式保守,文案经 $t 正常渲染)
export const TASK_STATUS_CLASS: Record<string, string> = {
todo: 'status-todo',
in_progress: 'status-progress',
review_ready: 'status-review',
merged: 'status-done',
abandoned: 'status-abandoned',
in_review: 'status-review',
testing: 'status-testing',
done: 'status-done',
blocked: 'status-blocked',
cancelled: 'status-abandoned',
}
export function taskStatusLabel(status: string): string {

View File

@@ -18,7 +18,24 @@ export default {
assignee: 'Assignee',
baseBranch: 'Base Branch',
workflowDef: 'Workflow Definition',
reviewRounds: 'Review Round {n}',
createdAt: 'Created At',
updatedAt: 'Updated At',
// F-05 advance buttons (shown by state-machine-legal next states, validated by advance_task)
advanceTitle: 'Advance Task',
// Button labels (legal transitions per task_state_machine.rs can_transition)
advance: {
toInProgress: 'Start',
toInReview: 'Submit for Review',
toTesting: 'Approve to Testing',
toDone: 'Done',
resume: 'Resume',
sendBack: 'Send Back',
sendBackToReview: 'Send Back to Review',
block: 'Block',
cancel: 'Cancel Task',
},
advancing: 'Advancing...',
advanceFailed: 'Failed to advance task',
},
}

View File

@@ -11,14 +11,16 @@ export default {
status: 'Status: ',
all: 'All',
},
// Status filters (with icons)
// Status filters (with icons) — D-260616-01 aligned to backend 7 states
statusFilter: {
all: 'All',
todo: 'Todo',
in_progress: 'In Progress',
review_ready: 'Review Ready',
merged: 'Merged',
abandoned: 'Abandoned',
in_review: 'In Review',
testing: 'Testing',
done: 'Done',
blocked: 'Blocked',
cancelled: 'Cancelled',
},
// Groups
@@ -44,19 +46,22 @@ export default {
priorityMedium: 'P2 Medium',
priorityLow: 'P3 Low',
},
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys)
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) — D-260616-01 aligned to backend 7 states
status: {
todo: '📋 Todo',
in_progress: '🔄 In Progress',
review_ready: '👀 Review Ready',
merged: '✅ Merged',
abandoned: '🗑️ Abandoned',
in_review: '👀 In Review',
testing: '🧪 Testing',
done: '✅ Done',
blocked: '🚫 Blocked',
cancelled: '🗑️ Cancelled',
},
// Error fallbacks (user-visible via state.error)
err: {
loadFailed: 'Failed to load tasks',
createFailed: 'Failed to create task',
deleteFailed: 'Failed to delete task',
updateFailed: 'Failed to update task',
},
},
}

View File

@@ -18,7 +18,24 @@ export default {
assignee: '负责人',
baseBranch: '基础分支',
workflowDef: '工作流定义',
reviewRounds: '第 {n} 轮 Review',
createdAt: '创建时间',
updatedAt: '更新时间',
// F-05 推进按钮(按状态机合法下一态显示,advance_task 状态机校验)
advanceTitle: '推进任务',
// 按钮文案(状态机合法转换,见 task_state_machine.rs can_transition)
advance: {
toInProgress: '开始', // todo/blocked → in_progress
toInReview: '提交审查', // in_progress → in_review
toTesting: '通过进测试', // in_review → testing
toDone: '完成', // testing → done
resume: '恢复', // blocked → in_progress(与 toInProgress 同向,blocked 专用文案)
sendBack: '退回修改', // in_review → in_progress
sendBackToReview: '退回重审', // testing → in_review
block: '阻塞', // * → blocked
cancel: '取消任务', // * → cancelled
},
advancing: '推进中...',
advanceFailed: '推进任务失败',
},
}

View File

@@ -11,14 +11,16 @@ export default {
status: '状态:',
all: '全部',
},
// 状态筛选(含图标)
// 状态筛选(含图标) — D-260616-01 对齐后端 7 态
statusFilter: {
all: '全部',
todo: '待开始',
in_progress: '进行中',
review_ready: '审查',
merged: '已合并',
abandoned: '已放弃',
in_review: '审查',
testing: '测试中',
done: '已完成',
blocked: '已阻塞',
cancelled: '已取消',
},
// 分组
@@ -44,19 +46,22 @@ export default {
priorityMedium: 'P2 中',
priorityLow: 'P3 低',
},
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key)
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态
status: {
todo: '📋 待开始',
in_progress: '🔄 进行中',
review_ready: '👀 审查',
merged: '✅ 已合并',
abandoned: '🗑️ 已放弃',
in_review: '👀 审查',
testing: '🧪 测试中',
done: '✅ 已完成',
blocked: '🚫 已阻塞',
cancelled: '🗑️ 已取消',
},
// 错误回退(state.error 用户可见)
err: {
loadFailed: '加载任务失败',
createFailed: '创建任务失败',
deleteFailed: '删除任务失败',
updateFailed: '更新任务失败',
},
},
}

View File

@@ -34,7 +34,7 @@
<span
class="value description ai-md"
v-if="task.description"
v-html="renderedDescription"
v-html="renderedDesc"
></span>
<span v-else class="value description"></span>
</div>
@@ -42,8 +42,27 @@
<span class="label">{{ $t('taskDetail.status') }}</span>
<span class="value">
<span 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.review_rounds > 0" class="review-rounds-badge">
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
</span>
</span>
</div>
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
<div v-if="advanceActions.length" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.advanceTitle') }}</span>
<div class="advance-actions">
<button
v-for="act in advanceActions"
:key="act.target"
type="button"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ $t(act.label) }}</button>
</div>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value">
@@ -95,9 +114,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { taskApi, projectApi } from '@/api'
import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown'
@@ -107,7 +127,7 @@ import {
priorityLabel,
priorityClass,
} from '../constants/project'
import type { TaskRecord, ProjectRecord } from '@/api/types'
import type { TaskRecord, ProjectRecord, DfDataChangedPayload } from '@/api/types'
const { t } = useI18n()
const route = useRoute()
@@ -116,12 +136,13 @@ const loading = ref(true)
const errorMsg = ref('')
const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([])
const advancing = ref(false)
const taskId = computed(() => route.params.id as string)
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
// computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDescription, ensureLoaded } = useRendered(
const { rendered: renderedDesc, ensureLoaded } = useRendered(
() => task.value?.description ?? '',
)
@@ -130,6 +151,76 @@ const projectName = computed(() => {
return p?.name ?? task.value?.project_id ?? '—'
})
// ============================================================
// 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 → 终态,无按钮
// variant: primary=前向推进主操作(每态至多一个),danger=取消,ghost=其余(阻塞/退回/恢复)
interface AdvanceAction {
target: string
label: string // i18n key(走 taskDetail.advance.*)
variant: string // btn-primary | btn-danger | btn-ghost
}
const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
todo: [
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_progress: [
{ target: 'in_review', label: 'taskDetail.advance.toInReview', variant: 'btn-primary' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
in_review: [
{ target: 'testing', label: 'taskDetail.advance.toTesting', variant: 'btn-primary' },
{ target: 'in_progress', label: 'taskDetail.advance.sendBack', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
testing: [
{ target: 'done', label: 'taskDetail.advance.toDone', variant: 'btn-primary' },
{ target: 'in_review', label: 'taskDetail.advance.sendBackToReview', variant: 'btn-ghost' },
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
blocked: [
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
],
// done / cancelled:终态,无推进按钮
}
const advanceActions = computed<AdvanceAction[]>(() => {
const s = task.value?.status
if (!s) return []
return ADVANCE_MAP[s] ?? []
})
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
errorMsg.value = ''
} catch (e: any) {
errorMsg.value = e?.toString() ?? t('taskDetail.advanceFailed')
} finally {
advancing.value = false
}
}
async function load() {
loading.value = true
errorMsg.value = ''
@@ -156,9 +247,31 @@ function refresh() {
// 路由参数变化(id 变化)时重新加载
watch(taskId, () => { load() })
onMounted(() => {
// 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()
// 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') {
load()
}
})
} catch (e) {
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
}
})
onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
})
</script>
@@ -189,6 +302,31 @@ onMounted(() => {
}
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* F-05 推进按钮变体(样式跟随 Projects/ProjectDetail/Knowledge 同款 btn-primary/btn-danger/btn-sm) */
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
.btn-danger:hover { background: rgba(240,101,101,0.12); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* F-05 推进操作区 */
.advance-row .advance-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 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;
}
/* ===== 状态/优先级标签 ===== */
.status-tag {
@@ -258,77 +396,7 @@ onMounted(() => {
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
.info-item .value.description { line-height: 1.5; }
/* ===== 任务描述 Markdown 渲染(B-24,样式同 AiChat .ai-md 收敛) ===== */
.description.ai-md :deep(p) { margin: 0 0 6px; }
.description.ai-md :deep(p:last-child) { margin-bottom: 0; }
.description.ai-md :deep(ul), .description.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
.description.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
.description.ai-md :deep(code) {
font-family: var(--df-font-mono);
font-size: 12px;
padding: 1px 5px;
background: rgba(255,255,255,0.06);
border-radius: var(--df-radius-sm);
color: var(--df-accent);
}
.description.ai-md :deep(pre) {
margin: 8px 0;
padding: 10px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
overflow-x: auto;
}
.description.ai-md :deep(pre code) {
padding: 0;
background: transparent;
border-radius: 0;
color: var(--df-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.description.ai-md :deep(blockquote) {
margin: 6px 0;
padding: 4px 12px;
border-left: 2px solid var(--df-accent);
color: var(--df-text-secondary);
}
.description.ai-md :deep(h1), .description.ai-md :deep(h2), .description.ai-md :deep(h3) {
font-weight: 500;
color: var(--df-text);
margin: 8px 0 4px;
}
.description.ai-md :deep(h1) { font-size: 16px; }
.description.ai-md :deep(h2) { font-size: 14px; }
.description.ai-md :deep(h3) { font-size: 13px; }
.description.ai-md :deep(a) {
color: var(--df-accent);
text-decoration: none;
}
.description.ai-md :deep(a:hover) { text-decoration: underline; }
.description.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
.description.ai-md :deep(hr) {
border: none;
border-top: 0.5px solid var(--df-border);
margin: 8px 0;
}
.description.ai-md :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 6px 0;
font-size: 12px;
overflow-x: auto;
display: block;
}
.description.ai-md :deep(th), .description.ai-md :deep(td) {
padding: 4px 8px;
border: 0.5px solid var(--df-border);
text-align: left;
}
.description.ai-md :deep(th) {
font-weight: 500;
background: var(--df-bg);
}
/* ===== 任务描述 Markdown 渲染(B-24,基础样式收敛至全局 ai-md.css) ===== */
.project-link {
color: var(--df-accent);

View File

@@ -143,13 +143,16 @@ const projectFilters = computed(() => [
...store.projects.map(p => ({ key: p.id, label: p.name })),
])
// D-260616-01:筛选器对齐后端 7 态(删 review_ready/merged/abandoned,加 in_review/testing/done/blocked/cancelled)
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
{ key: 'review_ready', label: t('tasks.statusFilter.review_ready'), icon: '👀' },
{ key: 'merged', label: t('tasks.statusFilter.merged'), icon: '' },
{ key: 'abandoned', label: t('tasks.statusFilter.abandoned'), icon: '🗑️' },
{ key: 'in_review', label: t('tasks.statusFilter.in_review'), icon: '👀' },
{ key: 'testing', label: t('tasks.statusFilter.testing'), icon: '🧪' },
{ key: 'done', label: t('tasks.statusFilter.done'), icon: '' },
{ key: 'blocked', label: t('tasks.statusFilter.blocked'), icon: '🚫' },
{ key: 'cancelled', label: t('tasks.statusFilter.cancelled'), icon: '🗑️' },
])
function getProjectName(projectId: string): string {
@@ -406,6 +409,9 @@ onMounted(async () => {
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* D-260616-01:7 态新增 — testing(橙警告,近 review 区分)/blocked(红 danger,与 cancelled 区分用实心边框) */
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
/* ===== 空状态(Y-2: loading/error/empty 兜底,样式参考 Projects.vue) ===== */
.empty-state {