重构: 前端 composable 拆分+详情页 Markdown 渲染+流式 memo+状态同步+bug 修复
- composables/useMarkdown 抽(AiChat 复用)+ai/* 流式核心+useAiSend catch 回滚 user msg(B-21)+useAiStream 超时文案(B-03)+useAiConversations 切换 token(FR-R1) - views/TaskDetail 新建(F-02)+描述 md+mdReady 响应式+字段同行布局(B-24/31) - views/ProjectDetail 多选审批 UI(F-01)+描述 md+mdReady+字段同行(B-25/31) - views/Ideas+Knowledge 描述 md+mdReady(B-25) - views/Tasks 项目切换联动(B-29)+task 详情跳转 - components/AiChat toast 失败提示(B-20)+流式块级 memo(ARC-08)+clean UI(AR-7)+stop 兜底(AR-5) - components/ToolCard 审批卡片可读化(AR-3) - stores useAiStore barrel(ARC-04)+del settings mock(ARC-01)+approve options(R-PD-5) - i18n aiTool/nav 中英对称(AR-3/9)+删 nav.decisions 死链(ARC-02) - router /tasks/:id(F-02)+global.css selection 可见(R-260615-01)+api task detail+App 删 decisions nav
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="task-detail">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<router-link to="/tasks" class="back-link">← 返回任务列表</router-link>
|
||||
<h1>{{ task?.title ?? '...' }}</h1>
|
||||
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</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" type="button" @click="refresh">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 加载/错误态 -->
|
||||
<div v-if="loading" class="empty-hint">加载中...</div>
|
||||
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div v-else-if="task" class="detail-grid">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>任务信息</h2>
|
||||
</div>
|
||||
<div class="task-info">
|
||||
<div class="info-item">
|
||||
<span class="label">标题</span>
|
||||
<span class="value">{{ task.title }}</span>
|
||||
</div>
|
||||
<div class="info-item info-block">
|
||||
<span class="label">描述</span>
|
||||
<!-- B-24:任务描述 Markdown 渲染(## 标题 / - 列表 等),v-html 经 useMarkdown DOMPurify sanitize -->
|
||||
<span
|
||||
class="value description ai-md"
|
||||
v-if="task.description"
|
||||
v-html="renderedDescription"
|
||||
></span>
|
||||
<span v-else class="value description">—</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">状态</span>
|
||||
<span class="value">
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">优先级</span>
|
||||
<span class="value">
|
||||
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">关联项目</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>
|
||||
<div class="info-item">
|
||||
<span class="label">分支</span>
|
||||
<span class="value">
|
||||
<span v-if="task.branch_name" class="branch-tag">
|
||||
<span class="branch-icon">⑂</span>{{ task.branch_name }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">负责人</span>
|
||||
<span class="value">{{ task.assignee ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">基础分支</span>
|
||||
<span class="value">{{ task.base_branch ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">工作流定义</span>
|
||||
<span class="value mono">{{ task.workflow_def_id ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ formatDate(task.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">更新时间</span>
|
||||
<span class="value">{{ formatDate(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { taskApi, projectApi } from '../api'
|
||||
import { formatDate } from '../utils/time'
|
||||
import { useMarkdown } from '../composables/useMarkdown'
|
||||
import {
|
||||
taskStatusLabel,
|
||||
taskStatusClass,
|
||||
priorityLabel,
|
||||
priorityClass,
|
||||
} from '../constants/project'
|
||||
import type { TaskRecord, ProjectRecord } from '../api/types'
|
||||
|
||||
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例)
|
||||
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
|
||||
|
||||
// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
|
||||
// 触发 computed 重算(B-24 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
|
||||
const renderedDescription = computed(() => {
|
||||
void mdReady.value
|
||||
return renderMd(task.value?.description ?? '')
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMsg = ref('')
|
||||
const task = ref<TaskRecord | null>(null)
|
||||
const projects = ref<ProjectRecord[]>([])
|
||||
|
||||
const taskId = computed(() => route.params.id as string)
|
||||
|
||||
const projectName = computed(() => {
|
||||
const p = projects.value.find(p => p.id === task.value?.project_id)
|
||||
return p?.name ?? task.value?.project_id ?? '—'
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const [t, ps] = await Promise.all([
|
||||
taskApi.get(taskId.value),
|
||||
// 项目列表用于解析 project_id → 项目名(独立入口,不依赖全局 store)
|
||||
projectApi.list(),
|
||||
])
|
||||
task.value = t
|
||||
projects.value = ps
|
||||
} catch (e: any) {
|
||||
task.value = null
|
||||
errorMsg.value = e?.toString() ?? '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
load()
|
||||
}
|
||||
|
||||
// 路由参数变化(id 变化)时重新加载
|
||||
watch(taskId, () => { load() })
|
||||
|
||||
onMounted(() => {
|
||||
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-detail { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; white-space: nowrap; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ===== 状态/优先级标签 ===== */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.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); }
|
||||
|
||||
.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); }
|
||||
|
||||
/* ===== 面板 ===== */
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr; gap: var(--df-gap-grid); }
|
||||
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
|
||||
/* ===== 信息列表 ===== */
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状 */
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-item.info-block {
|
||||
flex-direction: column;
|
||||
}
|
||||
.info-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 88px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.info-item .value { font-size: 14px; color: var(--df-text); flex: 1; min-width: 0; }
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.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: rgba(108, 99, 255, 0.06);
|
||||
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); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user