新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
311
src/views/AuditLog.vue
Normal file
311
src/views/AuditLog.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<header class="page-header">
|
||||
<h1>审批历史</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)。参数与结果仅展示摘要,完整数据留存本地数据库。</p>
|
||||
|
||||
<!-- 状态条 -->
|
||||
<div v-if="loading" class="empty-state">加载中…</div>
|
||||
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">暂无审计记录</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="audit-table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">请求时间</th>
|
||||
<th class="col-tool">工具</th>
|
||||
<th class="col-risk">风险</th>
|
||||
<th class="col-status">状态</th>
|
||||
<th class="col-decided">决策者</th>
|
||||
<th class="col-args">参数摘要</th>
|
||||
<th class="col-result">结果摘要</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in records" :key="r.id">
|
||||
<td class="col-time">
|
||||
<div class="time-rel">{{ formatRelativeZh(r.requested_at) }}</div>
|
||||
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
|
||||
</td>
|
||||
<td class="col-tool"><code class="tool-name">{{ r.tool_name }}</code></td>
|
||||
<td class="col-risk">
|
||||
<span class="risk-badge" :class="riskClass(r.risk_level)">{{ riskLabel(r.risk_level) }}</span>
|
||||
</td>
|
||||
<td class="col-status">
|
||||
<span class="status-tag" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
<td class="col-decided">
|
||||
<span v-if="r.decided_by" class="decided-tag" :class="decidedClass(r.decided_by)">{{ decidedLabel(r.decided_by) }}</span>
|
||||
<span v-else class="decided-none">—</span>
|
||||
</td>
|
||||
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
|
||||
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="!loading && !errorMsg" class="pager">
|
||||
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">上一页</button>
|
||||
<span class="pager-info">第 {{ page }} 页{{ hasMore ? '' : '(末页)' }}</span>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelativeZh, formatDate } from '@/utils/time'
|
||||
|
||||
// 本地 interface(不进 api/types.ts,避撞 B-42 agent)
|
||||
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应
|
||||
interface ToolExecutionRecord {
|
||||
id: string
|
||||
conversation_id: string | null
|
||||
tool_call_id: string
|
||||
tool_name: string
|
||||
arguments_brief: string
|
||||
result_brief: string | null
|
||||
status: string
|
||||
risk_level: string
|
||||
requested_at: string
|
||||
executed_at: string | null
|
||||
decided_by: string | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const records = ref<ToolExecutionRecord[]>([])
|
||||
const offset = ref(0)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
||||
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
|
||||
const hasMore = computed(() => records.value.length === PAGE_SIZE)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const list = await invoke<ToolExecutionRecord[]>('list_tool_executions', {
|
||||
limit: PAGE_SIZE,
|
||||
offset: offset.value,
|
||||
})
|
||||
records.value = list
|
||||
// 下一页拉到空数组 → 应回退到上一页(避免停在空页)
|
||||
if (list.length === 0 && offset.value > 0) {
|
||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
offset.value = 0
|
||||
void load()
|
||||
}
|
||||
function prevPage() {
|
||||
if (offset.value === 0) return
|
||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||
void load()
|
||||
}
|
||||
function nextPage() {
|
||||
offset.value += PAGE_SIZE
|
||||
void load()
|
||||
}
|
||||
|
||||
// ── 标签 / 样式映射 ──
|
||||
function riskLabel(r: string): string {
|
||||
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? r
|
||||
}
|
||||
function riskClass(r: string): string {
|
||||
return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low'
|
||||
}
|
||||
function statusLabel(s: string): string {
|
||||
return ({
|
||||
pending: '待审批',
|
||||
approved: '已批准',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
} as Record<string, string>)[s] ?? s
|
||||
}
|
||||
function statusClass(s: string): string {
|
||||
return ({
|
||||
pending: 'status-pending',
|
||||
approved: 'status-approved',
|
||||
rejected: 'status-rejected',
|
||||
executing: 'status-executing',
|
||||
completed: 'status-completed',
|
||||
failed: 'status-failed',
|
||||
} as Record<string, string>)[s] ?? 'status-pending'
|
||||
}
|
||||
function decidedLabel(d: string): string {
|
||||
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d
|
||||
}
|
||||
function decidedClass(d: string): string {
|
||||
return d === 'human' ? 'decided-human' : 'decided-auto'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-log {
|
||||
padding: var(--df-pad-page, 24px);
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
.page-desc {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.audit-table-wrap {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow: auto;
|
||||
}
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.audit-table thead th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.audit-table tbody td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
color: var(--df-text-secondary);
|
||||
vertical-align: top;
|
||||
}
|
||||
.audit-table tbody tr:last-child td { border-bottom: none; }
|
||||
.audit-table tbody tr:hover td { background: var(--df-sidebar-hover, rgba(255,255,255,0.03)); }
|
||||
|
||||
.col-time { min-width: 120px; }
|
||||
.col-tool { min-width: 130px; }
|
||||
.col-risk { width: 56px; }
|
||||
.col-status { width: 80px; }
|
||||
.col-decided { width: 64px; }
|
||||
.col-args { min-width: 200px; }
|
||||
.col-result { min-width: 220px; }
|
||||
|
||||
.time-rel { font-size: 12px; color: var(--df-text-secondary); }
|
||||
.time-abs { font-size: 10px; color: var(--df-text-dim); margin-top: 2px; font-family: var(--df-font-mono); }
|
||||
|
||||
.tool-name {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
background: var(--df-accent-bg, rgba(123,111,240,0.08));
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.brief {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 风险 badge */
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.risk-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.risk-medium { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.risk-high { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* 状态 tag */
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-pending { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-approved { background: rgba(76,175,80,0.2); color: var(--df-success, #4caf50); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-executing { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-completed { background: rgba(76,175,80,0.15); color: var(--df-success, #4caf50); }
|
||||
.status-failed { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* 决策者 tag */
|
||||
.decided-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.decided-human { background: rgba(123,111,240,0.2); color: var(--df-accent); }
|
||||
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.decided-none { color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 分页 ===== */
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pager-info {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -266,6 +266,17 @@
|
||||
@change="syncConcurrencyConfig" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAgentMaxIterations') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAgentMaxIterations') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="1" max="50" style="width:80px"
|
||||
v-model.number="settings.agentMaxIterations"
|
||||
@change="syncAgentMaxIterations" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -613,6 +624,8 @@ const settings = reactive({
|
||||
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
|
||||
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
|
||||
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
|
||||
// F-260616-01: Agentic 循环最大轮次,默认 10(与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐)
|
||||
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
|
||||
})
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
@@ -647,6 +660,10 @@ watch(() => settings.llmPerConvConcurrency, (v) => {
|
||||
void appSettings.set('df-llm-per-conv-concurrency', v)
|
||||
})
|
||||
|
||||
watch(() => settings.agentMaxIterations, (v) => {
|
||||
void appSettings.set('df-ai-agent-max-iterations', v)
|
||||
})
|
||||
|
||||
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
|
||||
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
|
||||
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -669,6 +686,24 @@ function syncConcurrencyConfig() {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 同步 Agentic 最大轮次到后端(debounce 300ms)
|
||||
// F-260616-01: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
|
||||
// 下次发消息才生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
||||
// 双 clamp:此处 1-50 + 后端 command 再 clamp 1-50,防越界输入致 loop 失效/失控
|
||||
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function syncAgentMaxIterations() {
|
||||
// clamp 1-50,防越界(过小致 agent 失能、过大致烧 token)
|
||||
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
|
||||
if (_agentIterTimer) clearTimeout(_agentIterTimer)
|
||||
_agentIterTimer = setTimeout(async () => {
|
||||
try {
|
||||
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
|
||||
} catch (e) {
|
||||
console.error('同步 Agent 最大轮次失败:', e)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(() => settings.language, (val) => {
|
||||
void appSettings.set('df-language', val)
|
||||
// 同步 i18n 实例,让界面立即跟随语言切换
|
||||
@@ -735,11 +770,14 @@ onMounted(() => {
|
||||
loadKnowledgeConfig()
|
||||
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
|
||||
syncConcurrencyConfig()
|
||||
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
|
||||
syncAgentMaxIterations()
|
||||
})
|
||||
|
||||
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
|
||||
onUnmounted(() => {
|
||||
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
|
||||
if (_agentIterTimer) clearTimeout(_agentIterTimer)
|
||||
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
||||
if (_toastTimer) clearTimeout(_toastTimer)
|
||||
})
|
||||
|
||||
@@ -63,6 +63,32 @@
|
||||
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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 互不干扰。 -->
|
||||
<div v-if="wfAdvanceAction" class="info-item info-block advance-row">
|
||||
<span class="label">{{ $t('taskDetail.workflowAdvanceTitle') }}</span>
|
||||
<div class="advance-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
:disabled="wfAdvancing || advancing"
|
||||
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
|
||||
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
|
||||
</div>
|
||||
<!-- B-41 轻量进度(监听 workflow-event: NodeStarted/NodeCompleted/WorkflowFailed)
|
||||
完整进度条/预估留后续批;完成/失败后任务态由后端回调推进,df-data-changed 自动刷新 task -->
|
||||
<div v-if="wfAdvancing || wfProgressHint" 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-else-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.priority') }}</span>
|
||||
<span class="value">
|
||||
@@ -119,6 +145,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { taskApi, projectApi } from '@/api'
|
||||
import { workflowApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
import {
|
||||
@@ -127,7 +154,7 @@ import {
|
||||
priorityLabel,
|
||||
priorityClass,
|
||||
} from '../constants/project'
|
||||
import type { TaskRecord, ProjectRecord, DfDataChangedPayload } from '@/api/types'
|
||||
import type { TaskRecord, ProjectRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -138,6 +165,24 @@ const task = ref<TaskRecord | null>(null)
|
||||
const projects = ref<ProjectRecord[]>([])
|
||||
const advancing = ref(false)
|
||||
|
||||
// ============================================================
|
||||
// F-260616-06 ①-1 / B-41 工作流推进状态(与手动 advance 的 advancing 独立,互不干扰)
|
||||
// ------------------------------------------------------------
|
||||
// wfAdvancing:推进中态;wfExecId:当前监听的工作流执行 ID(过滤 workflow-event);
|
||||
// wfTotalNodes/wfDoneCount/wfRunningNode:轻量进度(听 NodeStarted/NodeCompleted);
|
||||
// wfResult: 'completed' | 'failed' | null —— 终态提示,完成/失败后自动清空(由新一次推进重置)。
|
||||
const wfAdvancing = ref(false)
|
||||
const wfExecId = ref<string | null>(null)
|
||||
const wfTotalNodes = ref(0)
|
||||
const wfDoneCount = ref(0)
|
||||
const wfRunningNode = ref<string>('')
|
||||
const wfResult = ref<'completed' | 'failed' | null>(null)
|
||||
|
||||
const wfProgressHint = computed(() => wfResult.value !== null)
|
||||
const wfCompletedHint = computed(() => wfResult.value === 'completed')
|
||||
const wfFailedHint = computed(() => wfResult.value === 'failed')
|
||||
const wfDoneTotal = computed(() => wfTotalNodes.value)
|
||||
|
||||
const taskId = computed(() => route.params.id as string)
|
||||
|
||||
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
|
||||
@@ -206,6 +251,28 @@ const advanceActions = computed<AdvanceAction[]>(() => {
|
||||
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
|
||||
@@ -215,12 +282,87 @@ async function handleAdvance(target: string) {
|
||||
task.value = updated
|
||||
errorMsg.value = ''
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.toString() ?? t('taskDetail.advanceFailed')
|
||||
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
|
||||
wfRunningNode.value = ''
|
||||
wfDoneCount.value = 0
|
||||
wfTotalNodes.value = 0
|
||||
try {
|
||||
const execId = await workflowApi.run(
|
||||
`task-advance:${target}`,
|
||||
{}, // 空 dag → 后端 template_for(target) 选模板
|
||||
{}, // 全局 config 留空(节点级 config 由模板/运行时注入)
|
||||
task.value.id,
|
||||
target,
|
||||
)
|
||||
wfExecId.value = execId
|
||||
errorMsg.value = ''
|
||||
} catch (e: any) {
|
||||
wfAdvancing.value = false
|
||||
wfExecId.value = null
|
||||
errorMsg.value = t('taskDetail.workflowAdvanceFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||
}
|
||||
}
|
||||
|
||||
// B-41: 处理单个工作流事件(由 onEvent 回调 dispatch)。按 execution_id 过滤当前推进。
|
||||
function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
||||
if (!wfExecId.value || payload.execution_id !== wfExecId.value) return
|
||||
const evt = payload.event
|
||||
switch (evt?.type) {
|
||||
case 'node_started': {
|
||||
// 第一次 NodeStarted 时初始化 total(后端模板节点数,无法前端预知,
|
||||
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
|
||||
const node = String(evt.node_id ?? '')
|
||||
wfRunningNode.value = node
|
||||
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
|
||||
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
|
||||
break
|
||||
}
|
||||
case 'node_completed': {
|
||||
wfDoneCount.value += 1
|
||||
wfRunningNode.value = ''
|
||||
break
|
||||
}
|
||||
case 'workflow_completed': {
|
||||
wfAdvancing.value = false
|
||||
wfRunningNode.value = ''
|
||||
wfResult.value = 'completed'
|
||||
// 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据)
|
||||
setTimeout(() => {
|
||||
if (wfResult.value === 'completed') wfResult.value = null
|
||||
}, 3000)
|
||||
break
|
||||
}
|
||||
case 'workflow_failed':
|
||||
case 'node_failed': {
|
||||
if (evt?.type === 'workflow_failed') {
|
||||
wfAdvancing.value = false
|
||||
wfRunningNode.value = ''
|
||||
wfResult.value = 'failed'
|
||||
setTimeout(() => {
|
||||
if (wfResult.value === 'failed') wfResult.value = null
|
||||
}, 3000)
|
||||
}
|
||||
// node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
@@ -234,7 +376,7 @@ async function load() {
|
||||
projects.value = ps
|
||||
} catch (e: any) {
|
||||
task.value = null
|
||||
errorMsg.value = e?.toString() ?? t('taskDetail.loadFailed')
|
||||
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -252,6 +394,8 @@ watch(taskId, () => { load() })
|
||||
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
|
||||
// 故此本地监听 entity=task/project 时重载当前 task。
|
||||
let _unlistenDataChanged: (() => void) | null = null
|
||||
// B-41: 工作流推进事件 unlistener(听 NodeStarted/NodeCompleted/WorkflowFailed 显示轻量进度)
|
||||
let _unlistenWorkflowEvent: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||||
@@ -268,10 +412,18 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
|
||||
}
|
||||
// B-41: 工作流推进事件监听(复用 workflowApi.onEvent, 与 workflow store 的全局监听并存无冲突 —
|
||||
// 多个 listen 各自接收全量事件,本视图按 execution_id 过滤只处理自己发起的推进)
|
||||
try {
|
||||
_unlistenWorkflowEvent = await workflowApi.onEvent(handleWorkflowEvent)
|
||||
} catch (e) {
|
||||
console.error('[TaskDetail] 启动 workflow-event 监听失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||||
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -317,6 +469,19 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
Reference in New Issue
Block a user