<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>流程图可视化(支持 node_type)</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 0;
padding: 20px;
background: #f9f9f9;
}
#fileInput {
margin-bottom: 15px;
}
#error {
color: red;
margin-top: 10px;
display: none;
}
.graph-container {
position: relative;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.lines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
#levels {
position: relative;
z-index: 2;
}
.level {
display: flex;
gap: 30px;
margin-bottom: 50px;
justify-content: center;
flex-wrap: wrap;
}
.node {
flex: 1;
min-width: 240px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
padding: 14px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
position: relative;
transition: background-color 0.2s;
}
.node.node-main {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
}
.node.node-sub {
background-color: #f1f8e9;
border-left: 4px solid #7cb342;
}
.node-title {
font-weight: bold;
margin-bottom: 6px;
color: #333;
text-align: center;
font-size: 16px;
}
.node-type-label {
font-size: 12px;
margin-bottom: 8px;
text-align: center;
font-weight: normal;
}
.node-type-label.default {
color: #1976d2;
}
.node-type-label.subnode {
color: #689f38;
}
details > summary {
list-style: none;
cursor: pointer;
text-align: center;
color: #007bff;
text-decoration: underline;
margin: 6px 0;
font-size: 14px;
}
details > summary::-webkit-details-marker {
display: none;
}
.content-area {
margin-top: 8px;
}
.log-item {
white-space: pre-wrap;
word-break: break-word;
font-size: 14px;
line-height: 1.6;
margin: 8px 0;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.tab {
margin-right: 8px;
padding: 6px 12px;
background: #e9ecef;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.tab.active {
background: #007bff;
color: white;
border-color: #007bff;
}
</style>
</head>
<body>
<input type="file" id="fileInput" accept=".txt,.log" />
<div id="error"></div>
<div id="tabs"></div>
<div class="graph-container">
<svg class="lines" xmlns="http://www.w3.org/2000/svg"></svg>
<div id="levels"></div>
</div>
<script>
let graphs = [];
let currentGraphIndex = -1;
let nodeMap = new Map();
let resizeObserver = null;
function getValidPreStep(pre_node) {
if (pre_node == null || pre_node === '' || pre_node === 'null' || pre_node === 'undefined') {
return 'start';
}
return String(pre_node);
}
function clearAll() {
graphs = [];
currentGraphIndex = -1;
nodeMap.clear();
document.getElementById('tabs').innerHTML = '';
document.getElementById('levels').innerHTML = '';
const svg = document.querySelector('.lines');
if (svg) svg.innerHTML = '';
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
}
document.getElementById('fileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
clearAll();
const errorDiv = document.getElementById('error');
errorDiv.style.display = 'none';
try {
const text = await file.text();
const lines = text.split('\n').filter(line => line.trim() !== '');
const groups = {};
for (const line of lines) {
let log;
try {
log = JSON.parse(line);
} catch (err) {
throw new Error(`JSON 解析失败: ${line.substring(0, 60)}...`);
}
const { pre_node, cur_node, message_id, type, timestamp, content, node_type } = log;
if (cur_node == null || message_id == null || type == null || content == null) {
throw new Error(`缺少必要字段: ${JSON.stringify(log)}`);
}
const normalizedNodeType = (node_type === 'sub') ? 'sub' : 'main';
if (type !== 'input' && type !== 'output') continue;
const key = `${String(cur_node)}|${String(type)}|${String(message_id)}`;
if (!groups[key]) {
groups[key] = {
cur_node: String(cur_node),
type: String(type),
pre_node: getValidPreStep(pre_node),
node_type: normalizedNodeType,
parts: []
};
}
groups[key].parts.push({
ts: Number(timestamp),
content: String(content)
});
}
const stepData = {};
const allSteps = new Set();
for (const group of Object.values(groups)) {
allSteps.add(group.cur_node);
}
for (const step of allSteps) {
stepData[step] = { from: 'start', inputs: [], outputs: [], node_type: 'main' };
}
for (const group of Object.values(groups)) {
const fullContent = group.parts
.sort((a, b) => a.ts - b.ts)
.map(p => p.content)
.join('');
stepData[group.cur_node].from = group.pre_node;
stepData[group.cur_node].node_type = group.node_type;
if (group.type === 'input') {
stepData[group.cur_node].inputs.push(fullContent);
} else if (group.type === 'output') {
stepData[group.cur_node].outputs.push(fullContent);
}
}
const allFroms = new Set();
for (const data of Object.values(stepData)) {
if (data.from && data.from !== 'start') {
allFroms.add(data.from);
}
}
for (const from of allFroms) {
if (!stepData[from]) {
stepData[from] = { from: 'start', inputs: [], outputs: [], node_type: 'main' };
}
}
if (!stepData['start']) {
stepData['start'] = { from: null, inputs: [], outputs: [], node_type: 'main' };
}
const allNodes = Object.keys(stepData);
const graph = {};
allNodes.forEach(node => graph[node] = []);
for (const [node, data] of Object.entries(stepData)) {
if (data.from && stepData[data.from]) {
graph[data.from].push(node);
}
}
const levelMap = new Map();
const queue = ['start'];
levelMap.set('start', 0);
while (queue.length > 0) {
const node = queue.shift();
for (const child of graph[node] || []) {
if (!levelMap.has(child)) {
levelMap.set(child, levelMap.get(node) + 1);
queue.push(child);
}
}
}
let maxLevel = Math.max(...levelMap.values());
for (const node of allNodes) {
if (!levelMap.has(node)) {
levelMap.set(node, ++maxLevel);
}
}
const levels = {};
for (const [node, level] of levelMap.entries()) {
if (!levels[level]) levels[level] = [];
levels[level].push({
id: node,
name: node,
from: stepData[node]?.from || null,
inputs: stepData[node]?.inputs || [],
outputs: stepData[node]?.outputs || [],
node_type: stepData[node]?.node_type || 'main'
});
}
const sortedLevels = Object.entries(levels)
.map(([level, nodes]) => ({ level: parseInt(level), nodes }))
.sort((a, b) => a.level - b.level);
graphs = [{ id: 'graph-1', name: '流程图 1', levels: sortedLevels }];
renderTabs();
if (graphs.length > 0) switchGraph(0);
e.target.value = '';
} catch (err) {
console.error(err);
errorDiv.textContent = '错误: ' + (err.message || err);
errorDiv.style.display = 'block';
}
});
function renderTabs() {
const tabsDiv = document.getElementById('tabs');
tabsDiv.innerHTML = '';
graphs.forEach((graph, i) => {
const btn = document.createElement('button');
btn.className = 'tab';
btn.textContent = graph.name;
btn.onclick = () => switchGraph(i);
if (i === currentGraphIndex) btn.classList.add('active');
tabsDiv.appendChild(btn);
});
}
function switchGraph(index) {
currentGraphIndex = index;
renderGraph(graphs[index]);
renderTabs();
}
function renderGraph(graphData) {
const levelsDiv = document.getElementById('levels');
const svg = document.querySelector('.lines');
levelsDiv.innerHTML = '';
svg.innerHTML = '';
nodeMap.clear();
if (resizeObserver) {
resizeObserver.disconnect();
}
if (!graphData || !graphData.levels) {
levelsDiv.innerHTML = '<p>无数据</p>';
return;
}
resizeObserver = new ResizeObserver(() => {
drawLines(graphData);
});
graphData.levels.forEach(levelGroup => {
const levelDiv = document.createElement('div');
levelDiv.className = 'level';
levelGroup.nodes.forEach(node => {
const nodeDiv = document.createElement('div');
const nodeTypeClass = node.node_type === 'sub' ? 'node-sub' : 'node-main';
nodeDiv.className = `node ${nodeTypeClass}`;
nodeDiv.dataset.id = node.id;
let contentHtml = '';
if (node.inputs.length === 0 && node.outputs.length === 0) {
contentHtml = '<div class="content-area"><div class="log-item">(无内容)</div></div>';
} else {
contentHtml = '<div class="content-area">';
node.inputs.forEach((input, i) => {
contentHtml += `<div class="log-item"> input ${i + 1}:\n${input}</div>`;
});
node.outputs.forEach((output, i) => {
contentHtml += `<div class="log-item"> output ${i + 1}:\n${output}</div>`;
});
contentHtml += '</div>';
}
const nodeTypeLabel = node.node_type === 'sub' ? '子节点' : '主节点';
const nodeTypeLabelClass = node.node_type === 'sub' ? 'sub' : 'main';
nodeDiv.innerHTML = `
<div class="node-title">${node.name}</div>
<div class="node-type-label ${nodeTypeLabelClass}">${nodeTypeLabel}</div>
<details>
<summary>查看日志</summary>
${contentHtml}
</details>
`;
levelDiv.appendChild(nodeDiv);
nodeMap.set(node.id, nodeDiv);
resizeObserver.observe(nodeDiv);
const details = nodeDiv.querySelector('details');
if (details) {
details.addEventListener('toggle', () => {
setTimeout(() => drawLines(graphData), 0);
});
}
});
levelsDiv.appendChild(levelDiv);
});
setTimeout(() => drawLines(graphData), 100);
}
function drawLines(graphData) {
const svg = document.querySelector('.lines');
svg.innerHTML = '';
const container = svg.parentElement;
const containerRect = container.getBoundingClientRect();
const containerTop = containerRect.top + window.scrollY;
const containerLeft = containerRect.left + window.scrollX;
const edges = [];
graphData.levels.forEach(levelGroup => {
levelGroup.nodes.forEach(node => {
if (node.from && nodeMap.has(node.from) && nodeMap.has(node.id)) {
edges.push({ from: node.from, to: node.id });
}
});
});
edges.forEach(edge => {
const fromEl = nodeMap.get(edge.from);
const toEl = nodeMap.get(edge.to);
if (!fromEl || !toEl) return;
const fromRect = fromEl.getBoundingClientRect();
const toRect = toEl.getBoundingClientRect();
const x1 = fromRect.left + fromRect.width / 2 - containerLeft;
const y1 = fromRect.bottom - containerTop;
const x2 = toRect.left + toRect.width / 2 - containerLeft;
const y2 = toRect.top - containerTop;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
line.setAttribute('stroke', '#6c757d');
line.setAttribute('stroke-width', '2');
svg.appendChild(line);
});
}
</script>
</body>
</html>