Files
DevFlow/src/components/workflow/WorkflowDagDisplay.vue
T
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00

303 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-if="dag" class="wf-dag">
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
<!-- 拓扑分层渲染 -->
<div class="wf-dag-layers">
<div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer">
<div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div>
<div class="wf-dag-layer-nodes">
<div
v-for="node in layer"
:key="node.id"
class="wf-dag-node"
:class="nodeClass(node.id, props.nodeStatuses)"
>
<span class="wf-dag-node-status-dot" aria-hidden="true"></span>
<span class="wf-dag-node-name">{{ node.id }}</span>
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
</div>
</div>
<!-- 出边条件标签(同层节点出站边展示条件) -->
<div v-if="layerOutEdges[li]" class="wf-dag-outedges">
<div v-for="edge in layerOutEdges[li]" :key="edge.source + '→' + edge.target" class="wf-dag-outedge">
<span class="wf-dag-outedge-arrow">→ {{ edge.target }}</span>
<span class="wf-dag-cond-label" :class="{ 'wf-dag-cond-label--uncond': !edge.condition }">{{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }}</span>
</div>
</div>
<!-- 层间箭头 -->
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow" aria-hidden="true">↓</div>
</div>
</div>
<!-- 边条件编辑列表 -->
<details class="wf-dag-edges-detail">
<summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件编辑' }} ({{ dag.edges.length }})</summary>
<div class="wf-dag-edges">
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
<span class="wf-dag-edge-arrow">{{ edge.source }} → {{ edge.target }}</span>
<input
v-if="editingIndex === i"
ref="editInputRef"
class="wf-dag-edge-input"
:value="editValue"
@input="editValue = ($event.target as HTMLInputElement).value"
@keydown.enter="saveCondition(i)"
@keydown.escape="cancelEdit"
@blur="saveCondition(i)"
:placeholder="$t('taskDetail.workflowCondPlaceholder')"
/>
<span
v-else
class="wf-dag-cond-label"
:class="{ 'wf-dag-cond-label--uncond': !edge.condition, 'wf-dag-cond-label--editable': true }"
role="button"
tabindex="0"
@click="startEdit(i, edge)"
@keydown.enter.prevent="startEdit(i, edge)"
@keydown.space.prevent="startEdit(i, edge)"
>{{ edge.condition || $t('taskDetail.workflowUnconditional') }}</span>
</div>
</div>
</details>
</div>
<div v-else class="wf-dag wf-dag--empty">
{{ $t('taskDetail.workflowDagEmpty') || '暂无工作流数据' }}
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import type { NodeStatus } from '@/api/types'
export type { NodeStatus }
interface DagNode {
id: string
node_type: string
config?: Record<string, unknown>
}
interface EdgeDef {
source: string
target: string
condition?: string | null
}
const props = withDefaults(defineProps<{
dagJson: string
nodeStatuses?: Record<string, NodeStatus>
}>(), {
nodeStatuses: () => ({}),
})
const emit = defineEmits<{
(e: 'update:dagJson', val: string): void
}>()
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try {
const parsed = JSON.parse(props.dagJson)
// 结构校验:dag_json 可能为 {} / 缺 nodes / nodes 非数组(执行记录无 DAG 或异常),解析成功但
// 缺字段会致下方 for..of 抛 "d.nodes is not iterable"。非预期结构 → null 显示空态而非崩页。
if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
return parsed as { nodes: DagNode[]; edges: EdgeDef[] }
} catch { return null }
})
const nodeTypeLabel: Record<string, string> = {
'AiNode': 'AI',
'HumanNode': '人工审批',
'ScriptNode': '脚本',
'AiSelfReviewNode': 'AI 自审',
}
function statusLabel(s: NodeStatus): string {
return { pending: '⏳', running: '🔄', completed: '✅', failed: '❌', skipped: '⏭️' }[s] || ''
}
function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<string, boolean> {
const s = statuses[id]
return {
'wf-dag-node--running': s === 'running',
'wf-dag-node--completed': s === 'completed',
'wf-dag-node--failed': s === 'failed',
'wf-dag-node--skipped': s === 'skipped',
}
}
/** 拓扑分层 */
const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
if (!dag.value) return []
const d = dag.value
const inDegree: Record<string, number> = {}
const edgeMap: Record<string, string[]> = {}
for (const n of d.nodes) {
inDegree[n.id] = 0
edgeMap[n.id] = []
}
for (const e of d.edges) {
if (!edgeMap[e.source]) edgeMap[e.source] = []
edgeMap[e.source].push(e.target)
inDegree[e.target] = (inDegree[e.target] || 0) + 1
}
const result: (DagNode & { deps?: string[] })[][] = []
let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id)
const visited = new Set<string>()
while (queue.length > 0) {
const layer: DagNode[] = []
const next: string[] = []
for (const id of queue) {
if (visited.has(id)) continue
visited.add(id)
const node = d.nodes.find(n => n.id === id)
if (node) layer.push(node)
for (const target of (edgeMap[id] || [])) {
inDegree[target]--
if (inDegree[target] === 0) next.push(target)
}
}
if (layer.length > 0) result.push(layer)
queue = next
}
return result
})
/**
* 每层出边索引(缓存版):layers 变化时重新计算,避免模板内重复调用函数。
* 返回 { [layerIndex]: EdgeDef[] },仅含出边非空的层。
*/
const layerOutEdges = computed<Record<number, EdgeDef[]>>(() => {
if (!dag.value) return {}
const bySource: Record<string, EdgeDef[]> = {}
for (const e of dag.value.edges) {
if (!bySource[e.source]) bySource[e.source] = []
bySource[e.source].push(e)
}
const result: Record<number, EdgeDef[]> = {}
for (let li = 0; li < layers.value.length; li++) {
const edges: EdgeDef[] = []
for (const node of layers.value[li]) {
const out = bySource[node.id]
if (out) edges.push(...out)
}
if (edges.length > 0) result[li] = edges
}
return result
})
// ── 条件编辑 ──
const editingIndex = ref<number | null>(null)
const editValue = ref('')
const editInputRef = ref<HTMLInputElement | null>(null)
function startEdit(i: number, edge: EdgeDef) {
editingIndex.value = i
editValue.value = edge.condition || ''
nextTick(() => editInputRef.value?.focus())
}
function saveCondition(i: number) {
if (!dag.value || editingIndex.value !== i) return
const edges = [...dag.value.edges]
const val = editValue.value.trim()
edges[i] = { ...edges[i], condition: val || null }
const updated = { ...dag.value, edges }
emit('update:dagJson', JSON.stringify(updated, null, 2))
editingIndex.value = null
}
function cancelEdit() {
editingIndex.value = null
}
</script>
<style scoped>
.wf-dag {
margin-top: 8px; padding: 8px 12px;
background: var(--df-bg-card);
border-radius: var(--df-radius-sm, 4px); font-size: 12px;
}
.wf-dag--empty { color: var(--df-text-dim); }
.wf-dag-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
.wf-dag-layers { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.wf-dag-layer { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.wf-dag-layer-label { font-size: 10px; color: var(--df-text-dim); align-self: flex-start; }
.wf-dag-layer-nodes {
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
}
.wf-dag-layer-arrow { font-size: 16px; color: var(--df-text-dim); line-height: 1; }
.wf-dag-node {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px;
background: var(--df-bg);
border: 1px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
transition: border-color 0.3s, background 0.3s;
}
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
.wf-dag-node-type {
font-size: 10px; color: var(--df-text-dim);
background: var(--df-bg-card); padding: 0 4px; border-radius: 4px;
}
.wf-dag-node-status { font-size: 11px; }
.wf-dag-node-status-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--df-text-dim);
}
.wf-dag-node--running {
border-color: var(--df-info);
background: var(--df-info-bg);
}
.wf-dag-node--running .wf-dag-node-status-dot {
background: var(--df-info);
animation: pulse 1s ease-in-out infinite;
}
.wf-dag-node--completed { border-color: var(--df-success); }
.wf-dag-node--completed .wf-dag-node-status-dot { background: var(--df-success); }
.wf-dag-node--failed { border-color: var(--df-danger); background: var(--df-danger-bg); }
.wf-dag-node--failed .wf-dag-node-status-dot { background: var(--df-danger); }
.wf-dag-node--skipped { opacity: 0.5; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* 出边条件标签 */
.wf-dag-outedges {
display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 12px;
padding: 2px 0;
}
.wf-dag-outedge {
display: inline-flex; align-items: center; gap: 4px; font-size: 10px;
}
/* 箭头色:原 --df-text-primary 为未定义幽灵 token(声明失效),改主文字色 */
.wf-dag-outedge-arrow { color: var(--df-text); white-space: nowrap; }
/* 条件标签(共享:出边展示 + 编辑列表) */
.wf-dag-cond-label {
font-family: var(--df-font-mono); font-size: 10px; padding: 0 4px;
background: var(--df-info-bg); border-radius: 2px; color: var(--df-info);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim); }
.wf-dag-cond-label--editable {
cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 4px;
transition: background 0.15s;
}
.wf-dag-cond-label--editable:hover { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
.wf-dag-cond-label--uncond.wf-dag-cond-label--editable:hover { background: var(--df-bg-card); }
/* 边条件编辑列表 */
.wf-dag-edges-detail { margin-top: 8px; }
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info); }
.wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
.wf-dag-edge-arrow { color: var(--df-text); white-space: nowrap; }
.wf-dag-edge-input {
flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px;
padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 4px;
background: var(--df-bg); color: var(--df-text); outline: none;
}
.wf-dag-edge-input:focus { border-color: var(--df-accent); }
</style>