重构: 前端 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:
2026-06-15 05:14:56 +08:00
parent 2de0c6ecb7
commit 80b9243528
26 changed files with 997 additions and 207 deletions

View File

@@ -59,7 +59,13 @@
<h2 class="detail-title">{{ currentIdea.title }}</h2>
<span class="status-tag" :class="'status-' + currentIdea.status">{{ $t(statusLabelKey(currentIdea.status)) }}</span>
</div>
<p class="detail-desc">{{ currentIdea.description }}</p>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail),空值回退 -->
<p
v-if="currentIdea.description"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
<p v-else class="detail-desc"></p>
<!-- 对抗式评估 -->
<div class="detail-section">
@@ -208,8 +214,19 @@ import { useProjectStore } from '../stores/project'
import { formatDate } from '../utils/time'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useMarkdown } from '../composables/useMarkdown'
import type { IdeaRecord } from '../api/types'
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例)
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
const renderedDesc = computed(() => {
void mdReady.value
return renderMd(currentIdea.value?.description ?? '')
})
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
@@ -433,6 +450,7 @@ async function onStatusChange(e: Event) {
}
onMounted(async () => {
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
await store.loadIdeas()
})
</script>
@@ -797,6 +815,63 @@ onMounted(async () => {
margin-bottom: var(--df-gap-page);
}
/* ===== 灵感描述 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
.detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); }
.detail-desc.ai-md :deep(p) { margin: 0 0 6px; }
.detail-desc.ai-md :deep(p:last-child) { margin-bottom: 0; }
.detail-desc.ai-md :deep(ul), .detail-desc.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
.detail-desc.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
.detail-desc.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);
}
.detail-desc.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;
}
.detail-desc.ai-md :deep(pre code) {
padding: 0;
background: transparent;
border-radius: 0;
color: var(--df-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.detail-desc.ai-md :deep(blockquote) {
margin: 6px 0;
padding: 4px 12px;
border-left: 2px solid var(--df-accent);
color: var(--df-text-secondary);
}
.detail-desc.ai-md :deep(h1), .detail-desc.ai-md :deep(h2), .detail-desc.ai-md :deep(h3) {
font-weight: 500;
color: var(--df-text);
margin: 8px 0 4px;
}
.detail-desc.ai-md :deep(h1) { font-size: 16px; }
.detail-desc.ai-md :deep(h2) { font-size: 14px; }
.detail-desc.ai-md :deep(h3) { font-size: 13px; }
.detail-desc.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; }
.detail-desc.ai-md :deep(a:hover) { text-decoration: underline; }
.detail-desc.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
.detail-desc.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; }
.detail-desc.ai-md :deep(table) {
width: 100%; border-collapse: collapse; margin: 6px 0;
font-size: 12px; overflow-x: auto; display: block;
}
.detail-desc.ai-md :deep(th), .detail-desc.ai-md :deep(td) {
padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left;
}
.detail-desc.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); }
.detail-section {
margin-bottom: var(--df-gap-page);
}

View File

@@ -110,7 +110,13 @@
<div class="detail-field">
<label>{{ $t('knowledge.contentLabel') }}</label>
<div v-if="!editing" class="detail-content">{{ detail.knowledge.content }}</div>
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable( B-24 TaskDetail);编辑态保持 textarea -->
<div
v-if="!editing && detail.knowledge.content"
class="detail-content ai-md"
v-html="renderedContent"
></div>
<div v-else-if="!editing" class="detail-content"></div>
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
</div>
@@ -229,11 +235,22 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
import { useMarkdown } from '../composables/useMarkdown'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
const { t } = useI18n()
const store = useKnowledgeStore()
// B-260615-25:知识内容 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例)
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
// 内容渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
const renderedContent = computed(() => {
void mdReady.value
return renderMd(detail.value?.knowledge.content ?? '')
})
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
const topTab = ref<'library' | 'inbox'>('library')
@@ -495,6 +512,7 @@ function relativeTime(millisStr: string): string {
}
onMounted(() => {
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
store.loadList()
store.loadCandidates()
})
@@ -592,6 +610,63 @@ onMounted(() => {
.detail-field { margin-bottom: 12px; }
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
.detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
/* ===== 知识内容 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
.detail-content.ai-md { white-space: normal; }
.detail-content.ai-md :deep(p) { margin: 0 0 6px; }
.detail-content.ai-md :deep(p:last-child) { margin-bottom: 0; }
.detail-content.ai-md :deep(ul), .detail-content.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
.detail-content.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
.detail-content.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);
}
.detail-content.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;
}
.detail-content.ai-md :deep(pre code) {
padding: 0;
background: transparent;
border-radius: 0;
color: var(--df-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.detail-content.ai-md :deep(blockquote) {
margin: 6px 0;
padding: 4px 12px;
border-left: 2px solid var(--df-accent);
color: var(--df-text-secondary);
}
.detail-content.ai-md :deep(h1), .detail-content.ai-md :deep(h2), .detail-content.ai-md :deep(h3) {
font-weight: 500;
color: var(--df-text);
margin: 8px 0 4px;
}
.detail-content.ai-md :deep(h1) { font-size: 16px; }
.detail-content.ai-md :deep(h2) { font-size: 14px; }
.detail-content.ai-md :deep(h3) { font-size: 13px; }
.detail-content.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; }
.detail-content.ai-md :deep(a:hover) { text-decoration: underline; }
.detail-content.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
.detail-content.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; }
.detail-content.ai-md :deep(table) {
width: 100%; border-collapse: collapse; margin: 6px 0;
font-size: 12px; overflow-x: auto; display: block;
}
.detail-content.ai-md :deep(th), .detail-content.ai-md :deep(td) {
padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left;
}
.detail-content.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); }
.muted { color: var(--df-text-dim); font-size: 12px; }

View File

@@ -110,9 +110,15 @@
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
<span>{{ formatDate(currentProject.updated_at) }}</span>
</div>
<div class="info-item">
<div class="info-item info-block">
<span class="label">{{ $t('projectDetail.description') }}</span>
<span>{{ currentProject.description || '—' }}</span>
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail) -->
<span
v-if="currentProject.description"
class="value description ai-md"
v-html="renderedDesc"
></span>
<span v-else></span>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
@@ -153,7 +159,6 @@
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning">{{ $t('projectDetail.runDemoWorkflow') }}</button>
</div>
<div class="log-list" ref="logListRef">
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
@@ -176,17 +181,39 @@
<p style="margin: 16px 0; color: var(--df-text-secondary);">{{ store.pendingApproval.description }}</p>
<div style="margin-bottom: 20px;">
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<!-- F-260615-01: select_type=multiple checkbox 多选,缺省 single 按钮单选 -->
<template v-if="isMultipleSelect">
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;">
<label
v-for="(option, idx) in store.pendingApproval.options"
:key="idx"
style="display: flex; align-items: center; gap: 8px; cursor: pointer;"
>
<input type="checkbox" :value="option" v-model="multiDecisions" />
<span>{{ option }}</span>
</label>
</div>
<button
v-for="(option, idx) in store.pendingApproval.options"
:key="idx"
class="btn"
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
@click="handleApproval(option)"
class="btn btn-primary"
:disabled="multiDecisions.length === 0"
@click="handleApprovalMulti"
>
{{ option }}
确认({{ multiDecisions.length }})
</button>
</div>
</template>
<template v-else>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button
v-for="(option, idx) in store.pendingApproval.options"
:key="idx"
class="btn"
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
@click="handleApproval(option)"
>
{{ option }}
</button>
</div>
</template>
</div>
</div>
<div class="modal-actions">
@@ -214,6 +241,17 @@ import { parseStack } from '../utils/project'
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useMarkdown } from '../composables/useMarkdown'
// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例)
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
const renderedDesc = computed(() => {
void mdReady.value
return renderMd(currentProject.value?.description ?? '')
})
const route = useRoute()
const router = useRouter()
@@ -253,29 +291,10 @@ const projectTasks = computed(() =>
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
// ── 工作流 ──
const workflowRunning = ref(false)
const demoDag = {
nodes: [
{ id: 'n1', node_type: 'script', label: '环境检查', config: { command: 'echo "Environment OK"', timeout_secs: 10 } },
{ id: 'n2', node_type: 'script', label: '运行测试', config: { command: 'echo "Tests passed"', timeout_secs: 10 } },
{ id: 'n3', node_type: 'script', label: '构建产物', config: { command: 'echo "Build success"', timeout_secs: 10 } },
],
edges: [
{ from: 'n1', to: 'n2' },
{ from: 'n2', to: 'n3' },
],
}
async function runDemoWorkflow() {
workflowRunning.value = true
store.clearLiveEvents()
try {
await store.runWorkflow('demo-pipeline', demoDag)
} finally {
workflowRunning.value = false
}
}
// demoDag + runDemoWorkflow 已下线R-PD-2"script" 节点不再注册到 NodeRegistry
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
// 工作流执行日志面板保留,实时事件展示能力不依赖 demoDag。
// 待真实工作流需求落地(独立 BuildNode + 审批链)时重建演示入口。
// ── 实时日志格式化 ──
interface LogEntry {
@@ -397,6 +416,18 @@ async function handleApproval(decision: string) {
showApprovalDialog.value = false
}
// F-260615-01: 多选审批
const isMultipleSelect = computed(() => store.pendingApproval?.select_type === 'multiple')
const multiDecisions = ref<string[]>([])
async function handleApprovalMulti() {
if (multiDecisions.value.length === 0) return
// decision 传首项(向后兼容),decisions 透传全量
await store.approveHumanApproval(multiDecisions.value[0], undefined, [...multiDecisions.value])
multiDecisions.value = []
showApprovalDialog.value = false
}
// ── 取消审批(调 cancel_workflow_node IPC 置节点 Cancelled──
async function handleCancelApproval() {
await store.cancelHumanApproval()
@@ -420,6 +451,8 @@ watch(formattedEvents, () => {
// 监听审批请求
watch(() => store.pendingApproval, (newApproval) => {
if (newApproval) {
// F-260615-01: 每次新审批清空多选状态(避免上轮残留)
multiDecisions.value = []
showApprovalDialog.value = true
}
}, { immediate: true })
@@ -429,6 +462,7 @@ watch(() => store.pendingApproval, (newApproval) => {
// ── 生命周期 ──
onMounted(async () => {
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
await store.loadProjects()
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
unlisten = await store.startEventListener()
@@ -456,10 +490,15 @@ onUnmounted(() => {
gap: var(--df-gap-grid);
}
/* B-260615-31:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状 */
.info-item {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 12px;
}
.info-item.info-block {
flex-direction: column;
gap: 4px;
}
.info-item .label {
@@ -467,6 +506,8 @@ onUnmounted(() => {
color: var(--df-text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 88px;
flex-shrink: 0;
}
.info-item a.idea-link {
@@ -508,6 +549,63 @@ onUnmounted(() => {
background: rgba(108, 99, 255, 0.08); color: var(--df-text-secondary);
border: 0.5px solid var(--df-border);
}
/* ===== 项目描述 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
.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); }
</style>
<style scoped>

362
src/views/TaskDetail.vue Normal file
View File

@@ -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>

View File

@@ -49,7 +49,7 @@
<span class="group-count">{{ $t('tasks.group.taskCount', { n: group.tasks.length }) }}</span>
</div>
<div class="task-list">
<div class="task-item" v-for="task in group.tasks" :key="task.id">
<div class="task-item" v-for="task in group.tasks" :key="task.id" @click="router.push(`/tasks/${task.id}`)">
<div class="task-main">
<div class="task-title-row">
<span class="task-title">{{ task.title }}</span>
@@ -101,13 +101,15 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '../stores/project'
import { formatRelativeZh } from '../utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
import type { TaskRecord } from '../api/types'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
@@ -209,6 +211,11 @@ async function confirmCreate() {
showCreateModal.value = false
}
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)
watch(activeProject, (key) => {
store.loadTasks(key === 'all' ? undefined : key)
})
onMounted(async () => {
await Promise.all([store.loadProjects(), store.loadTasks()])
})